diff --git a/.github/workflows/gitee-mirror.yml b/.github/workflows/gitee-mirror.yml
new file mode 100644
index 000000000..92deb6419
--- /dev/null
+++ b/.github/workflows/gitee-mirror.yml
@@ -0,0 +1,83 @@
+name: Gitee mirror
+
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ run_id:
+ description: Original CI run that published the current GitHub release
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ group: gitee-mirror
+ cancel-in-progress: false
+ queue: max
+
+jobs:
+ mirror:
+ if: >-
+ github.repository == 'TNTcraftHIM/Piik' && github.ref == 'refs/heads/main' &&
+ (github.event_name == 'workflow_dispatch' ||
+ (github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_branch == 'main' &&
+ github.event.workflow_run.conclusion == 'success'))
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ # Execute trusted main tooling; upstream artifacts are data, never code.
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: .node-version
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: Resolve the published source
+ id: source
+ run: node scripts/release-mirror-source.mjs "$PIIK_SOURCE_RUN_ID"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PIIK_SOURCE_RUN_ID: ${{ inputs.run_id || github.event.workflow_run.id }}
+ - name: Download original Server artifacts
+ if: steps.source.outputs.mirror == 'true'
+ uses: actions/download-artifact@v8
+ with:
+ github-token: ${{ github.token }}
+ run-id: ${{ steps.source.outputs.run_id }}
+ pattern: piik-server*-${{ steps.source.outputs.revision }}
+ path: ${{ runner.temp }}/mirror-release
+ merge-multiple: true
+ - name: Download original App artifacts
+ if: steps.source.outputs.mirror == 'true'
+ uses: actions/download-artifact@v8
+ with:
+ github-token: ${{ github.token }}
+ run-id: ${{ steps.source.outputs.run_id }}
+ pattern: piik-app-*-${{ steps.source.outputs.revision }}
+ path: ${{ runner.temp }}/mirror-release
+ merge-multiple: true
+ - name: Mirror and verify original packages
+ if: steps.source.outputs.mirror == 'true'
+ run: >-
+ node scripts/mirror-release.mjs
+ "$RUNNER_TEMP/mirror-release" "$PIIK_RELEASE_VERSION" "$PIIK_RELEASE_REVISION"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
+ PIIK_RELEASE_VERSION: ${{ steps.source.outputs.version }}
+ PIIK_RELEASE_REVISION: ${{ steps.source.outputs.revision }}
+ - name: Explain recovery
+ if: failure() && steps.source.outputs.mirror == 'true'
+ run: |
+ printf 'Mirror failed; GitHub publication and Website are independent. Retry this workflow with original CI run %s. If artifacts expired, recover the retained original build output; do not rebuild this version. See docs/operations/github.md.\n' "$PIIK_SOURCE_RUN_ID" >> "$GITHUB_STEP_SUMMARY"
+ env:
+ PIIK_SOURCE_RUN_ID: ${{ steps.source.outputs.run_id }}
diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml
index 93f2d04ed..cccd3e960 100644
--- a/.github/workflows/repository-hygiene.yml
+++ b/.github/workflows/repository-hygiene.yml
@@ -17,6 +17,27 @@ on:
description: Optional candidate version (vMAJOR.MINOR.PATCH); never publishes
required: false
type: string
+ network_lab:
+ description: Run the isolated Linux network laboratory (never publishes)
+ required: false
+ default: none
+ type: choice
+ options: [none, verify, smoke, full]
+ network_cases:
+ description: Optional comma-separated laboratory cases (empty runs the selected suite)
+ required: false
+ type: string
+ network_prediction:
+ description: NAT enhancement setting (compare runs paired on/off trials)
+ required: false
+ default: 'on'
+ type: choice
+ options: ['on', 'off', compare]
+ network_repeat:
+ description: Repetitions per laboratory case (1..20)
+ required: false
+ default: '1'
+ type: string
permissions:
contents: read
@@ -75,7 +96,7 @@ jobs:
${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PIIK_RELEASES_ENABLED == 'true' && 'plan' || 'build' }}
--github-output
- name: Preview release notes
- if: steps.release.outputs.publish == 'true' || inputs.candidate_version != ''
+ if: steps.release.outputs.publish == 'true'
run: node scripts/release-notes.mjs "$PIIK_RELEASE_VERSION" "$GITHUB_SHA" --github-summary
env:
PIIK_RELEASE_VERSION: ${{ steps.release.outputs.version }}
@@ -89,11 +110,13 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
- go-version: 1.26.6
+ go-version-file: go.mod
cache-dependency-path: go.sum
- name: Check Go core
run: npm run check:go-core
- - name: Check NAT and media ownership with the race detector
+ - name: Check reachable Go vulnerabilities
+ run: npm run check:go-vuln
+ - name: Check concurrent resource ownership with the race detector
run: npm run check:go-race
- name: Type-check and test
run: npm run typecheck && npm test
@@ -101,6 +124,56 @@ jobs:
PIIK_BUILD_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_REVISION: ${{ github.sha }}
+ - name: Prepare isolated network laboratory
+ if: github.event_name == 'workflow_dispatch' && inputs.network_lab != 'none' && inputs.network_lab != ''
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y nftables iproute2 tcpdump conntrack bison flex libmnl-dev libcap-dev
+ mkdir -p build/network-lab/bin
+ CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/piik-server ./cmd/piik-server
+ CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/piik-app ./cmd/piik-app
+ CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/stun ./scripts/network-lab/stun
+ # The runner's iproute2 6.1 lacks netem's seed parameter. Keep this tool
+ # local to the opt-in lab; do not replace system networking utilities.
+ - name: Check out laboratory traffic-control tool
+ if: github.event_name == 'workflow_dispatch' && inputs.network_lab != 'none' && inputs.network_lab != ''
+ uses: actions/checkout@v6
+ with:
+ repository: iproute2/iproute2
+ ref: d2a8ffe85ac64f91ee2aa7679a5046f321b54127 # v6.17.0
+ path: build/network-lab/iproute2
+ persist-credentials: false
+ - name: Build laboratory traffic-control tool
+ if: github.event_name == 'workflow_dispatch' && inputs.network_lab != 'none' && inputs.network_lab != ''
+ run: |
+ make -C build/network-lab/iproute2 -j2 SUBDIRS='lib tc netem' TC_CONFIG_NO_XT=y
+ echo "$GITHUB_WORKSPACE/build/network-lab/iproute2/tc" >> "$GITHUB_PATH"
+ - name: Run isolated network laboratory
+ id: network_lab
+ if: github.event_name == 'workflow_dispatch' && inputs.network_lab != 'none' && inputs.network_lab != ''
+ timeout-minutes: 45
+ env:
+ CHROME_PATH: /usr/bin/google-chrome
+ TC_LIB_DIR: ${{ github.workspace }}/build/network-lab/iproute2/netem
+ PIIK_NETWORK_LAB_SUITE: ${{ inputs.network_lab }}
+ PIIK_NETWORK_LAB_CASES: ${{ inputs.network_cases }}
+ PIIK_NETWORK_LAB_PREDICTION: ${{ inputs.network_prediction }}
+ PIIK_NETWORK_LAB_REPEAT: ${{ inputs.network_repeat }}
+ run: |
+ if [ "$PIIK_NETWORK_LAB_SUITE" = verify ] || [ "$PIIK_NETWORK_LAB_SUITE" = full ]; then
+ sudo --preserve-env=PATH,CHROME_PATH,TC_LIB_DIR "$(command -v node)" --import tsx scripts/network-lab.ts --verify
+ fi
+ if [ "$PIIK_NETWORK_LAB_SUITE" != verify ]; then
+ sudo --preserve-env=PATH,CHROME_PATH,TC_LIB_DIR "$(command -v node)" --import tsx scripts/network-lab.ts --suite "$PIIK_NETWORK_LAB_SUITE" --cases "$PIIK_NETWORK_LAB_CASES" --prediction "$PIIK_NETWORK_LAB_PREDICTION" --repeat "$PIIK_NETWORK_LAB_REPEAT"
+ fi
+ - name: Save network laboratory evidence
+ if: always() && steps.network_lab.outcome != 'skipped' && github.event_name == 'workflow_dispatch' && inputs.network_lab != 'none' && inputs.network_lab != ''
+ uses: actions/upload-artifact@v7
+ with:
+ name: network-lab-${{ github.sha }}
+ path: build/network-lab/results/
+ if-no-files-found: error
+ retention-days: 7
release-server:
needs: validate
@@ -132,7 +205,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
- go-version: 1.26.6
+ go-version-file: go.mod
cache-dependency-path: go.sum
- name: Install dependencies
run: npm ci
@@ -173,8 +246,10 @@ jobs:
include:
- target: windows-amd64
runner: windows-latest
+ # Native Linux capture links system libraries; keep both architectures
+ # on the same explicit build baseline instead of following latest.
- target: linux-amd64
- runner: ubuntu-latest
+ runner: ubuntu-24.04
- target: linux-arm64
runner: ubuntu-24.04-arm
- target: darwin-arm64
@@ -196,7 +271,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
- go-version: 1.26.6
+ go-version-file: go.mod
cache-dependency-path: go.sum
# scripts/assemble-app.mjs runs `npm run build:web` for the Web
# assets the App binary embeds, so Vite and the license inputs must be
@@ -290,7 +365,6 @@ jobs:
# Local descriptors/manifests/checksums are validated before publishing.
# Release assets contain packages only; checksums appear in the release body.
- name: Publish packages with release notes and checksums
- id: github_release
run: >-
node scripts/publish-release.mjs
"$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA"
@@ -304,12 +378,3 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }}
- - name: Mirror verified release to Gitee
- if: ${{ !cancelled() && steps.github_release.outcome == 'success' }}
- run: >-
- node scripts/mirror-release.mjs
- "$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA"
- env:
- GH_TOKEN: ${{ github.token }}
- GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
- PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8b2cb698e..937ea45e8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,6 +21,16 @@ PR; maintainers can help identify what still needs verification.
讨论时请结合实际场景提出建议,尊重不同意见;分享截图或日志前移除隐私内容。
无法运行的检查请如实说明,便于维护者协助验证。
+Review contributions by their practical value, evidence and fit with the product,
+not their line count. A small fix that identifies and resolves a real defect is
+valuable. Duplicate work already independently resolved, personal preferences or
+low-value changes may be declined; merging is not an attribution mechanism.
+Retain authorship for accepted contributions and credit substantive discoveries
+accurately, without attributing independently written code to a PR author.
+评审看实际价值、证据和产品定位,不看代码行数。发现并解决真实问题的小修复同样值得接纳;
+已经独立解决的重复工作、个人偏好或收益很低的改动可以不合并,不为增加贡献者名单而合并。
+保留已接纳贡献的作者信息,如实说明有价值的问题发现;独立编写的代码不应冒记为他人共同编写。
+
## Lean Workflow
This is the default for an ordinary scoped implementation or bug fix.
@@ -101,9 +111,12 @@ does not establish coverage.
The server core is Go, so its acceptance runs here. It builds the Web UI first when
that output is missing, because both binaries embed it. CI invokes these same
package commands rather than rebuilding their steps in YAML.
-- `npm run check:go-race` checks port mapping, media-edge ownership and their
- scoped NAT dependencies with Go's race detector. It requires a supported cgo
- toolchain; CI runs it on Linux.
+- `npm run check:go-race` checks port mapping, media-edge and native-control
+ ownership, loopback delivery, service storage shutdown and scoped NAT dependencies with Go's race detector. It
+ requires a supported cgo toolchain; CI runs it on Linux.
+- `npm run check:go-vuln` checks the App and Server entry points and their
+ dependencies against the Go vulnerability database. CI uses the Go version
+ declared in `go.mod`; update that owner for required toolchain security fixes.
- `npm run check:native` runs only the current platform's capture compile/probe
and deterministic native regressions. The Linux App packaging job includes
its headless GStreamer output-profile and retirement test; this does not
@@ -121,6 +134,10 @@ does not establish coverage.
result. A manual diagnostic may locate a failure, but is not retained as pass
evidence. Existing `PIIK_CLIENT_*` gate environment names retain their current
contract; renaming the tools does not change those overrides.
+- `gate:network-lab` runs the opt-in [controlled network laboratory](./docs/research/network-lab.md).
+ It requires a disposable Linux runner with namespace administration. Its own
+ routers contain all firewall and traffic-shaping changes; do not run it on a
+ production service or treat its model results as a public success rate.
- On Windows, execute App/Server and Go test binaries only from stable
project build paths. Go's temporary compiler/cache files are not firewall
identities; do not run network tests with a changing temporary executable
diff --git a/README.md b/README.md
index 3ab75c0af..57939608d 100644
--- a/README.md
+++ b/README.md
@@ -71,8 +71,8 @@ device and adjust your voice volume.
Piik follows your system language: Chinese or English, with English as the fallback.
Use **中 / EN / ✦** in the header to switch language or try the visual mode.
-The App offers a local room, a temporary public link, or a connection to your own
-site. Keep the App running while sharing through it.
+The App can create a local room or temporary public invitation, and stays
+available to supply capture for an approved site. Keep it running while sharing.
Download a ZIP beginning with **`piik-app`**. The filename also identifies the platform:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8398ca907..781b3e714 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -66,7 +66,7 @@ Piik 是一个免费开源的私密屏幕共享工具,适合游戏直播、一
Piik 默认跟随系统语言,中文系统显示简体中文,其他语言显示英文。
顶部的 **中 / EN / ✦** 可切换中英文或体验纯视觉模式。
-Piik App 提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
+Piik App 可创建本地房间或临时公网邀请,也能在后台为已授权站点提供采集能力。分享期间需保持 App 运行。
下载以 **`piik-app`** 开头的 ZIP 压缩包,再按文件名中的平台标识选择:
diff --git a/cmd/piik-app/README.md b/cmd/piik-app/README.md
index 7e487a5a2..9499a761d 100644
--- a/cmd/piik-app/README.md
+++ b/cmd/piik-app/README.md
@@ -24,12 +24,11 @@ publication are separate steps in [deployment](../../docs/deployment.md).
## Modes
-- With no mode argument, the App opens a small launcher in the system
- Browser. It selects Local, a temporary public HTTPS invitation, or a saved
- Site, then enters the normal Host page.
-- The launcher remembers the last mode confirmed with **Open Piik** and the saved
- Site address. Without a saved mode, it selects Site when an address is saved,
- otherwise Public invite; it waits for confirmation before starting.
+- With no mode argument, the App opens its launcher and waits for use by an
+ approved Site. Choose **Local room** or **Public invite** to start a room
+ service on this device and enter the normal Host page.
+- The launcher remembers the last mode confirmed with **Open Piik**. Without a
+ saved Local/Public choice, it selects Public invite and waits for confirmation.
The mode preference is stored beside the settings in `client.json.mode`;
[App configuration](../../docs/standards/configuration.md#piik-app-configuration)
covers saved preferences and custom paths.
@@ -49,18 +48,22 @@ media-server forwarding (SFU) or TURN relay fallback. The
For Local invitations, the App automatically selects a sole active address or
sole private IPv4. If several addresses remain possible, choose an interface
-and IP in the launcher. Public invite and Site mode need no LAN selection.
+and IP in the launcher. Public invite and use through a Site need no LAN selection.
When bypassing the launcher with `--local`, use `--lan-address
` to
resolve an ambiguous choice. The selected address is checked again at startup;
it affects Local invitation links, while ICE selects media paths independently.
-Site mode uses the configured Site's rooms and any enabled SFU fallback.
-An App-opened Site remembers native activation, so later pages there may reuse
-the running App. Browser capture remains available without the App; see the
+To use a Piik Site, keep the App running and click **App** at the top of the Site.
+Confirm its address on the local approval page. The App remembers one approved
+Site, which the launcher can open or revoke. Approving another replaces it and
+ends the previous Site's App connections; Local rooms remain independent.
+The browser remembers activation, so later visits reuse the running App with
+that Site's rooms and any enabled SFU fallback. Browser capture remains available
+without the App; see the
[entry workflow](../../docs/standards/presentation-lifecycle.md#product-surface).
-The Host page offers Browser capture and available native windows or screens;
-select the source explicitly. Windows native capture offers VP8/Auto/H264;
+The Host page offers Browser and Camera capture alongside available native
+windows or screens; select the source explicitly. Windows native capture offers VP8/Auto/H264;
Auto chooses one codec for the share. macOS and Linux native capture require
hardware H264. Source and audio support vary by platform; their setup is covered
in the [Windows](../../native/capture/windows/README.md),
@@ -87,7 +90,8 @@ The terminal shows the current mode, entry links and startup state; its language
follows the launcher and App-enabled pages.
Press `o` to reopen the Browser, or `q` / Ctrl+C to end Local
rooms and stop the local server and temporary public link. Plain-text output
-uses Ctrl+C. The App keeps running after a Site tab closes.
+uses Ctrl+C. The Linux desktop entry also opens this terminal. The App keeps
+running after a Site tab closes.
When launched in its own Windows console, a startup or runtime error leaves the
error visible until Enter is pressed. Normal shutdown, existing terminals and
redirected or automated runs exit directly.
@@ -111,7 +115,7 @@ For codec-specific failures, see [Windows H264 troubleshooting](../../docs/guide
### Diagnostics
-Enable **Debug launch** in the mode selector before opening Piik, reproduce the
+Enable **Debug launch** in the launcher before starting a room or connecting a Site, reproduce the
problem, then press `D` in the interactive terminal to export a local ZIP.
Use `--debug` for failures before the selector opens. Exporting does not stop
the share or upload the archive. For Browser diagnostics, click the **Debug**
@@ -143,7 +147,7 @@ go build -o build/dev/piik-app.exe ./cmd/piik-app
./build/dev/piik-app.exe
```
-These commands build the App only. Choose **Local room** or a configured Site
+These commands build the App only. Choose **Local room** or open a Piik Site
for Browser capture. Native capture and **Public invite** need their helpers;
use `--capture-process` / `--tunnel-process` to select built helpers, or follow
[packaging](#packaging) for a complete bundle.
@@ -258,16 +262,6 @@ CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
npm run gate:app-native-host
-PIIK_CLIENT_NATIVE_HOST_GATE=true \
-PIIK_CLIENT_CROSS_NAT_GATE=true \
-CHROME_PATH=/path/to/chrome \
-PIIK_GO=/path/to/go \
-PIIK_REMOTE_HOST= \
-PIIK_REMOTE_USER= \
-PIIK_REMOTE_SSH_KEY=/path/to/key \
-PIIK_CLIENT_GATE_STUN_URLS=stun::3478 \
-npm run gate:app-native-host
-
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_LINK_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
@@ -291,11 +285,12 @@ The Windows media gate proves one hardware-H.264
capture generation, shared Pion source, Browser decode, PLI recovery, and STUN
candidate gathering. The native Host gate proves room creation and native video
delivery through the current route; native audio is included when the capability
-probe and target OS support it. The cross-NAT variant uses a temporary reverse
-SSH path for signaling only and requires a selected `srflx` or `prflx` media pair;
-media never travels through SSH. The one-link media variant instead carries the
-same signaling through the App's temporary public origin and requires direct
-media delivery to an independent Linux peer. Native P2P quality evidence and
-embedded SFU delivery have explicit gates. macOS and Linux capture still
+probe and target OS support it. The one-link media variant carries signaling
+through the App's temporary public origin and requires direct video RTP delivery
+to an independent Linux peer, with a selected `srflx` or `prflx` candidate.
+SSH only runs the remote probe; media never travels through it. Without a remote
+host, this variant checks decoded media in a local Browser through the public
+invitation; that does not establish cross-network connectivity. Native P2P
+quality evidence and embedded SFU delivery have explicit gates. macOS and Linux capture still
require physical desktop/media gates; CI compilation and package smoke do not
substitute for them.
diff --git a/cmd/piik-app/README.zh-CN.md b/cmd/piik-app/README.zh-CN.md
index 8555b0fa5..7224ae22c 100644
--- a/cmd/piik-app/README.zh-CN.md
+++ b/cmd/piik-app/README.zh-CN.md
@@ -21,10 +21,10 @@ Apple 芯片或 Intel Mac,以及 macOS 13 或更新版本。构建程序包与
## 运行模式
-- 不传模式参数时,App 在系统浏览器中打开启动页。选择本地房间、临时公网 HTTPS 邀请
- 或已保存的站点后,进入通常的房主页面。
-- 启动页会记住上次点击 **进入 Piik** 确认的模式,以及保存的站点地址。
- 没有已保存的模式时,有站点地址就默认选中站点,否则选中公网邀请;确认后才启动。
+- 不传模式参数时,App 打开启动页,等待已授权站点使用。选择 **本地房间** 或
+ **公网邀请**,可在这台设备上启动房间服务,进入通常的房主页面。
+- 启动页会记住上次点击 **进入 Piik** 确认的模式。
+ 没有已保存的本地/公网选择时,默认选中公网邀请;确认后才启动。
模式偏好保存在配置旁的 `client.json.mode` 中;保存项与自定义路径见
[App 配置](../../docs/standards/configuration.md#piik-app-configuration)。
- `--site`、`--local` 和 `--link` 用于在 CI 或开发时直接选择模式。
@@ -39,15 +39,17 @@ Apple 芯片或 Intel Mac,以及 macOS 13 或更新版本。构建程序包与
[路由参考](../../docs/standards/routing-transport.md)。
本地邀请会自动选择唯一的活动地址或唯一的私有 IPv4 地址。仍有多个可能地址时,
-请在启动页选择网卡和 IP。公网邀请和站点模式无需选择局域网地址。
+请在启动页选择网卡和 IP。公网邀请和搭配站点使用时无需选择局域网地址。
通过 `--local` 跳过启动页时,可用 `--lan-address ` 指定地址来解决多地址歧义。
App 会在启动时再次检查所选地址;它只影响本地邀请链接,媒体路径仍由 ICE 独立选择。
-站点模式使用所配置站点的房间,以及该站点已启用的 SFU 兜底。
-通过 App 打开的站点会记住原生功能的启用选择,之后打开该站点的页面可继续使用正在运行的 App。
+使用 Piik 站点时,保持 App 运行,点击站点顶部的 **App**,在本机确认页核对网址并允许使用。
+App 记住一个已授权站点,可在启动页打开或取消授权。允许另一个站点会替换旧站点,
+结束旧站点的 App 连接;本地房间不受影响。同一浏览器记住启用选择,
+之后访问站点即可继续使用 App,同时使用该站点的房间和已启用的 SFU 兜底。
没有 App 时仍可使用浏览器采集,详见[进入流程](../../docs/standards/presentation-lifecycle.md#product-surface)。
-房主页面提供浏览器采集及可用的原生窗口或屏幕,请明确选择来源。
+房主页面提供浏览器、摄像头采集,以及可用的原生窗口或屏幕,请明确选择来源。
Windows 原生采集提供 VP8/Auto/H264,Auto 会为本次分享选定一种编码。
macOS 和 Linux 原生采集需要硬件 H264。可选来源与音频支持取决于平台,设置要求见
[Windows](../../native/capture/windows/README.md)、[macOS](../../native/capture/darwin/README.md)
@@ -66,7 +68,7 @@ App 配置保存可选的本地站点口令。留空表示本地站点开放进
终端显示当前模式、入口链接与启动状态,语言跟随启动页和已启用 App 的页面。
按 `o` 重新打开浏览器,按 `q` 或 Ctrl+C 结束本地房间,停止本地服务与临时公网链接。
-纯文本输出时使用 Ctrl+C。关闭站点标签页后,App 仍会继续运行。
+纯文本输出时使用 Ctrl+C。Linux 桌面入口也会打开此终端。关闭站点标签页后,App 仍会继续运行。
App 在独立 Windows 控制台中启动时,启动或运行错误会保留在窗口中,按 Enter 后退出。
正常关闭、在已有终端中运行、重定向输出或自动化运行时,会直接退出。
@@ -87,7 +89,7 @@ App 在独立 Windows 控制台中启动时,启动或运行错误会保留在
### 诊断
-在启动页开启 **诊断启动**,进入 Piik 并复现问题,再在交互终端按 `D` 导出本地 ZIP。
+在启动房间或连接站点前,先在启动页开启 **诊断启动**,复现问题后在交互终端按 `D` 导出本地 ZIP。
启动页出现前就失败时,使用 `--debug`。导出不会停止分享或上传文件。
浏览器诊断通过主题按钮后的 **诊断** 芯片图标开启:确认重新加载后,使用 **网页报告** 下载。
调查浏览器与 App 配合的问题时,请提供同一次复现的两份报告。
@@ -116,7 +118,7 @@ go build -o build/dev/piik-app.exe ./cmd/piik-app
./build/dev/piik-app.exe
```
-这些命令只构建 App。体验浏览器采集时,选择 **本地房间** 或配置好的站点。
+这些命令只构建 App。体验浏览器采集时,选择 **本地房间**,或直接打开 Piik 站点。
原生采集和 **公网邀请** 需要各自的辅助程序,可用 `--capture-process` / `--tunnel-process`
指定已构建的程序,或按[打包](#打包)步骤生成完整程序包。
@@ -213,16 +215,6 @@ CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
npm run gate:app-native-host
-PIIK_CLIENT_NATIVE_HOST_GATE=true \
-PIIK_CLIENT_CROSS_NAT_GATE=true \
-CHROME_PATH=/path/to/chrome \
-PIIK_GO=/path/to/go \
-PIIK_REMOTE_HOST= \
-PIIK_REMOTE_USER= \
-PIIK_REMOTE_SSH_KEY=/path/to/key \
-PIIK_CLIENT_GATE_STUN_URLS=stun::3478 \
-npm run gate:app-native-host
-
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_LINK_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
@@ -246,7 +238,8 @@ Windows media gate 验证一个硬件 H.264 采集代次、共享 Pion 来源、
STUN 候选收集。native Host gate 验证房间创建及当前路由上的原生视频传输;
能力探测与目标系统支持时,也包含原生音频。
-跨 NAT 变体仅将临时反向 SSH 路径用于信令,并要求选中的媒体候选对包含 `srflx` 或 `prflx`;
-媒体不会经过 SSH。公网邀请媒体变体通过 App 临时公网来源传送相同信令,
-并要求将媒体直接传给独立的 Linux 对端。原生 P2P 质量证据与内嵌 SFU 传输有各自的验证入口。
+公网邀请媒体变体通过 App 临时公网地址传送信令,要求将视频 RTP 直接传给独立的 Linux 对端,
+且选中的候选对包含 `srflx` 或 `prflx`。SSH 仅运行远端探针,媒体不会经过 SSH。
+不配置远端主机时,该变体在本机浏览器中通过公网邀请检查解码画面,不能据此认定跨网络连通。
+原生 P2P 质量证据与内嵌 SFU 传输有各自的验证入口。
macOS 和 Linux 采集仍需实机桌面与媒体验证,CI 编译及程序包冒烟检查不能替代这些验证。
diff --git a/cmd/piik-peer-gate/main.go b/cmd/piik-peer-gate/main.go
index 50d2065f7..85a7c56c5 100644
--- a/cmd/piik-peer-gate/main.go
+++ b/cmd/piik-peer-gate/main.go
@@ -68,6 +68,7 @@ type incoming struct {
Revision int `json:"revision"`
ConnectionID string `json:"connectionId"`
Phase string `json:"phase"`
+ State string `json:"state"`
Candidate struct {
ConnectionID string `json:"connectionId"`
} `json:"candidate"`
@@ -316,7 +317,7 @@ func run(ctx context.Context, config gateConfig) gateResult {
return gateResult{Error: "receiver signaling failed"}
}
case "route-status":
- if message.Phase == "failed" {
+ if message.State == "failed" {
return gateResult{Error: "route assignment failed"}
}
}
@@ -384,10 +385,14 @@ func (receiver *receiver) createPeer(raw []wireICEServer) error {
pc.OnTrack(func(track *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) {
receiver.recordSelectedPair(rtpReceiver)
for {
- _, _, readErr := track.ReadRTP()
+ packet, _, readErr := track.ReadRTP()
if readErr != nil {
return
}
+ // Audio or padding alone cannot prove video delivery.
+ if track.Kind() != webrtc.RTPCodecTypeVideo || len(packet.Payload) == 0 {
+ continue
+ }
receiver.packetMu.Lock()
receiver.packets++
count := receiver.packets
diff --git a/cmd/piik-peer-gate/main_test.go b/cmd/piik-peer-gate/main_test.go
index 26e4e3997..a7d66e861 100644
--- a/cmd/piik-peer-gate/main_test.go
+++ b/cmd/piik-peer-gate/main_test.go
@@ -1,10 +1,54 @@
package main
import (
+ "context"
"encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
)
+func TestRunReportsRouteFailure(t *testing.T) {
+ done := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ defer conn.CloseNow()
+ var auth json.RawMessage
+ if err := wsjson.Read(r.Context(), conn, &auth); err != nil {
+ t.Error(err)
+ return
+ }
+ if err := wsjson.Write(r.Context(), conn, protocol.RouteStatusMessage{
+ Type: "route-status", Revision: 1, State: "failed", Reason: "route-exhausted",
+ }); err != nil {
+ t.Error(err)
+ return
+ }
+ <-done
+ }))
+ defer server.Close()
+ defer close(done)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ result := run(ctx, gateConfig{
+ SignalURL: "ws" + strings.TrimPrefix(server.URL, "http"),
+ Origin: server.URL, RoomID: "1234", ClientID: "remote_test", ViewerGrant: "test",
+ })
+ if result.Passed || result.Error != "route assignment failed" {
+ t.Fatalf("expected the route failure, got %+v", result)
+ }
+}
+
func TestIncomingUsesCurrentWireFieldNames(t *testing.T) {
payload := []byte(`{
"type":"route-update",
diff --git a/docs/README.md b/docs/README.md
index 1e43eed6d..2fcd189f3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -34,7 +34,7 @@ Technical references keep one shared version.
You can try the Browser application locally with Node from
[.node-version](../.node-version), npm from
-[package.json](../package.json), and Go 1.26 from [go.mod](../go.mod).
+[package.json](../package.json), and Go from [go.mod](../go.mod).
Run these commands from the repository root.
Start the web UI:
diff --git a/docs/adr/0008-window-scoped-audio-capture.md b/docs/adr/0008-window-scoped-audio-capture.md
index bca4a55ca..fdb3f0716 100644
--- a/docs/adr/0008-window-scoped-audio-capture.md
+++ b/docs/adr/0008-window-scoped-audio-capture.md
@@ -2,7 +2,7 @@
- Status: accepted for Browser capture and the Windows App audio paths
- Date: 2026-08-21
-- Last updated: 2026-10-06
+- Last updated: 2026-10-12
## Context
@@ -40,9 +40,9 @@ platform probe and physical gate report it.
[Native App media](../research/native-client-media.md).
7. Windows App screen sharing can optionally exclude one selected process tree
through WASAPI's exclusion mode after successful activation of that mode.
- Probe Windows 10 build 19041+ to include backported implementations, following
- the same capability boundary as application capture. Microsoft's documented
- minimum remains 20348; a Windows version alone does not establish support.
+ Probe inclusion and exclusion independently without a Windows build-number
+ gate. Successful activation establishes availability; unsupported activation
+ leaves that capability unavailable.
This mode spans all render endpoints; it is not restricted to the
default playback device. Reuse source enumeration and the existing PCM/mixed
output. Generic Browser capture and arbitrary unrelated process sets are not
diff --git a/docs/adr/0009-optional-nat-prediction.md b/docs/adr/0009-optional-nat-prediction.md
index 28d9ad34a..57a49bb55 100644
--- a/docs/adr/0009-optional-nat-prediction.md
+++ b/docs/adr/0009-optional-nat-prediction.md
@@ -25,9 +25,10 @@ connection acquisition reuses the route controller's existing opportunity ledger
switch defaults on, remains Host-controllable, locks while sharing, and is not
persisted beyond that share generation.
2. An enabled Site derives ports 3479 and 3480 from its first ordinary STUN
- authority on UDP 3478. Public-link App mode instead supplies one ordinary
- public STUN destination and two fixed public survey destinations; pure LAN
- mode supplies none. The same share switch and ICE configuration cover every
+ authority on UDP 3478. Public-link App mode supplies three ordinary public
+ STUN destinations, reusing the same set for its prediction survey; disabling
+ prediction retains ordinary discovery redundancy. Pure LAN mode supplies
+ none. The same share switch and ICE configuration cover every
Browser or Native P2P edge. SFU PeerConnections remain unchanged.
3. For one ICE gathering generation, the adapter observes only UDP `srflx`
candidates belonging to the exact three-destination survey set. Browser
@@ -66,6 +67,25 @@ connection acquisition reuses the route controller's existing opportunity ledger
recovery follows the normal route-failed path. A prepared candidate remains owned by the route
operation and is reported failed to that operation instead of starting a
separate ICE-restart loop.
+6. In the 2.0 candidate, a server-issued retry (never the first attempt) may
+ prepare an authenticated Native socket at both ends before creating its one
+ PeerConnection. It reuses the current peer/session/connection authorization
+ and the same share switch; Browser peers decline immediately. Each Engine
+ permits one preparation: an ordered three-destination observation, then
+ checks against one bounded peer endpoint. Three equal observations retain
+ that exact socket; a positive sequential observation closes it before opening
+ at most eight temporary sockets. These cases can pair in either direction;
+ neither observation establishes a permanent NAT classification. The bound
+ remains 64 requests and 64 authenticated replies. Survey readers, writers
+ and cancellation callbacks retire before socket reuse or transfer. The page
+ waits at most 3.5 seconds; the App
+ expires unconsumed work after five seconds. Mutual success and the existing
+ SDP offer commit one proved socket and peer tuple to Pion. Other sockets and
+ workers retire before reuse. Missing observations or unsuccessful preparation
+ resume ordinary ICE within that same route operation; a lost commit fails
+ only that operation. No route deadline, copy capacity or media protocol is
+ replaced. This is a bounded Native extension, not a Magicsock tunnel or a
+ mechanism for random-port NATs or blocked UDP.
## Consequences
@@ -73,8 +93,8 @@ Positive:
- Sites with their own listeners and self-contained public-link Apps can use
the same bounded mechanism while pure Browser/LAN operation stays unchanged;
-- normal ICE remains immediately available, so an unavailable auxiliary STUN
- listener or unsuccessful prediction cannot delay direct or SFU fallback;
+- the first ordinary ICE attempt remains immediately available; optional Native
+ preparation occupies only part of an existing retry's unchanged deadline;
- prediction is bounded to one connection generation and one small candidate
set; and
- selected-foundation attribution can validate actual field benefit without
@@ -84,8 +104,9 @@ Negative:
- the Browser may still reject synthetic candidates or the NAT may not have a
matching mapping;
-- enabled P2P connections perform two extra STUN transactions and
- may add connectivity checks for up to eight adjacent UDP ports;
+- Hosted prediction adds two survey destinations; public-link Apps reuse their
+ ordinary destinations. Prediction may add connectivity checks for up to eight
+ adjacent UDP ports;
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
intervening mappings, so room-wide coverage is not a success guarantee; and
- enabled acquisition can occupy up to four ordinary operation windows when
diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md
index 1f113fcc8..9a36940f8 100644
--- a/docs/adr/0010-cross-platform-client-runtime.md
+++ b/docs/adr/0010-cross-platform-client-runtime.md
@@ -46,7 +46,7 @@ topologies and makes a saved Site unavailable while another room source runs.
public process identity, not authentication. The listener accepts the current
Local Host origin and the one user-saved Site origin. Origin and Host
validation plus the Browser's local-network permission own this boundary.
-4. Loopback v9 starts with a strict `hello` handshake. Up to two participant
+4. Native control starts with a strict `hello` handshake. Up to two participant
control sessions may coexist; each retains its own share and media lifetime.
This bounds concurrent Native work, not the shared server's room count.
Ending one control session cannot end another; App exit waits for all
@@ -69,11 +69,15 @@ topologies and makes a saved Site unavailable while another room source runs.
wrapper or UI.
6. The system Browser remains the UI. Browser extensions, userscripts, Electron,
Tauri, and resident services need new evidence before they can replace this
- smaller boundary. An App-opened Site stores a non-secret opt-in at that
- exact Browser origin, so later manually opened pages may discover the running
- App. Pages without that opt-in do not probe localhost or request local-
- network permission. Clearing Site data simply requires opening it from the
- App again.
+ smaller boundary. An App-opened or explicitly connected Site stores a
+ non-secret opt-in at that exact Browser origin, so later pages may discover
+ the running App. Without this opt-in, only the user's App connection action
+ starts discovery. Its public loopback discovery returns process identity and
+ a local approval URL, never native capability, source or preference data.
+ The App-owned approval page displays the exact requesting origin and requires
+ confirmation before saving it. It rejects cross-origin mutations and framing;
+ the Site cannot approve itself. A popup message requests an authorized health
+ recheck, not authorization. Clearing Site data requires connecting again.
7. A self-contained Local deployment serves reachable LAN peers without a
central Piik service. Its explicit `--link` mode starts one accountless
Cloudflare Quick Tunnel for the same HTTP/WebSocket surface. The App
@@ -130,19 +134,20 @@ topologies and makes a saved Site unavailable while another room source runs.
extra threshold, timer, score, route operation, or representation ladder is
added. Only a consumed App-launch marker travels in the URL fragment.
Exact target identity travels over loopback.
-9. The App uses the system Browser as its only UI. On every launch, the current
- lightweight control center offers Local, public link, and Site. The Site value
- is stored in App configuration and remains one click on later launches;
- selecting Local or public link does not disable background RPC access for the
- saved Site. Command-line mode
- selectors remain automation inputs rather than the normal interface. An
+9. The App uses the system Browser as its only UI. It remains available to the
+ saved Site before a room mode is chosen. Its persistent launcher offers Local
+ and public link, shows the approved Site and allows revocation. Starting a
+ Local/public room does not disable that Site's native access or approval UI;
+ neither visiting the launcher nor approving a Site creates a room. Command-line
+ entry selectors remain automation inputs rather than the normal interface. An
embedded shell requires a reproduced product failure and one comparative
decision.
10. Local mode is one explicit server composition: static current assets,
memory-only rooms, peer-assisted media, no SQLite or SFU, and localhost plus
current LAN IPv4 origins. It uses no public discovery by default. Public-link
- mode adds its temporary HTTPS origin, one ordinary public STUN destination,
- and two bounded public survey destinations. Site mode consumes that Site's
+ mode adds its temporary HTTPS origin and the bounded public STUN discovery
+ and survey configuration in [ADR-0009](0009-optional-nat-prediction.md).
+ A participant on an approved Site consumes that Site's
configured STUN survey. Both feed the same connection-local prediction
adapter; Native additionally owns its UDP socket and best-effort port mapping.
11. One App configuration owns the optional Site origin and an optional,
@@ -151,12 +156,21 @@ topologies and makes a saved Site unavailable while another room source runs.
App bootstraps a selected page through a fragment consumed before
authentication; the page retains only the non-secret App opt-in at its
origin. Friends use the existing room invitation grant. No App-specific
- room authorization system is added.
+ room authorization system is added. Site replacement/revocation uses this
+ same configuration owner, immediately denies new control requests from the
+ old origin and cancels its existing sessions. Retirement is bounded and does
+ not wait for unrelated Local sessions. Cleanup failure never restores the
+ revoked permission; concurrent approval pages must review a changed Site
+ before replacing it.
12. Local authority shutdown first ends every in-memory room through the current
`room-closed` path, then closes signaling and HTTP. The App cancels the
server context, calls that end-then-close sequence under one bounded
- timeout, and only then closes the public tunnel and the loopback service. It
- does not restart a vanished authority.
+ timeout, and only then closes the public tunnel. Interactive room failure
+ leaves the resident App, approved Site sessions and approval UI available;
+ the launcher invalidates the failed room's target and reports its error.
+ Process exit additionally retires the loopback service. Explicit CLI room
+ launches still return their failure to the caller. Neither path restarts a
+ vanished authority.
13. App assembly consumes the immutable application release from the same full
Git revision and an explicit supported target. The Go binary embeds that
release's built Browser assets and the package `REVISION` records the same
@@ -230,6 +244,7 @@ architectural claim.
- [Cloudflare Tunnel WebSocket support](https://developers.cloudflare.com/cloudflare-one/faq/cloudflare-tunnels-faq/)
- [RFC 6455 WebSocket Origin model](https://www.rfc-editor.org/rfc/rfc6455.html)
- [Chrome Local Network Access](https://developer.chrome.com/blog/local-network-access)
+- [Browser popup message origin/source checks](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#security_concerns)
- [Discord local RPC](https://docs.discord.com/developers/topics/rpc)
- [OBS WebSocket protocol](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md)
- [LocalSend protocol](https://github.com/localsend/protocol/blob/main/README.md)
diff --git a/docs/assets/room-en-dark.png b/docs/assets/room-en-dark.png
index 7d6deaf5b..6512a7795 100644
Binary files a/docs/assets/room-en-dark.png and b/docs/assets/room-en-dark.png differ
diff --git a/docs/assets/room-en-light.png b/docs/assets/room-en-light.png
index 7be87121c..a674d138e 100644
Binary files a/docs/assets/room-en-light.png and b/docs/assets/room-en-light.png differ
diff --git a/docs/assets/room-zh-dark.png b/docs/assets/room-zh-dark.png
index 1f2f47a21..0ea23b1fe 100644
Binary files a/docs/assets/room-zh-dark.png and b/docs/assets/room-zh-dark.png differ
diff --git a/docs/assets/room-zh-light.png b/docs/assets/room-zh-light.png
index d4d0122e9..20dce42a2 100644
Binary files a/docs/assets/room-zh-light.png and b/docs/assets/room-zh-light.png differ
diff --git a/docs/deployment.md b/docs/deployment.md
index c1019f099..9d523c9d7 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -33,7 +33,8 @@ backup; Git and immutable artifacts own history.
## Build Host
-Start from a clean exact revision with Node.js 24, npm 11, and Go 1.26:
+Start from a clean exact revision using Node from [`.node-version`](../.node-version),
+npm from [`package.json`](../package.json), and Go from [`go.mod`](../go.mod):
```sh
npm ci
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 7b03b958b..c92ca7f39 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -98,22 +98,29 @@ See the [App guide](../../cmd/piik-app/README.md) for package and runtime detail
| Mode | When to choose it | What to keep in mind |
| --- | --- | --- |
-| **Local room** | Friends are on the same local network. | If several entries appear under **Local invitation address**, choose the interface and IP on your friends' network. Your computer must be reachable from theirs. A local site passphrase is optional. |
+| **Local room** | Friends are on the same local network. | With multiple interfaces, expand **Local invitation address** and select the interface and IP on your friends' network. Your computer must be reachable from theirs. A local site passphrase is optional. |
| **Public invite** | Friends are elsewhere and you want a temporary invitation. | Needs Internet access. The public address lasts for this App run. |
-| **Connect to Site** | Your group already has a Piik site. | Enter its address; the App adds capture capabilities to the site's sharing page. |
For Local room and Public invite, expand **Site passphrase** to set an optional
access passphrase.
-For **Connect to Site**, enter a domain such as `demo.piik.tv` or paste the full
-site address, then choose **Open Piik**. HTTPS is added when omitted; for an HTTP
-site, include `http://`. Enter the site's passphrase on the page that opens if asked.
-After opening the site through the App once, the same browser remembers that
-entry. Keep the App running with that site configured and you can visit the
-address directly. **Start sharing** opens the selector; choose **Apps / Windows**
-or **Screens** for App capture. Allow local-network access if the browser asks.
-In another browser, in a private window, or after clearing site data, enter through
-the App again. For a different site, update the address in the App first.
+**Already have a Piik site?** Keep the App running, open the site and click
+**App** at the top. Check the address on the local confirmation page, then choose
+**Allow access**. Enter any site passphrase on the site itself. Allow local-network
+access if the browser asks. No room mode needs to be selected in the launcher.
+You can also choose **Connect Piik App** inside the **Start sharing** source
+selector; its list updates as soon as the connection completes.
+If connection fails, the App button offers **Retry** and **Download App**.
+You can still share and watch in your browser.
+
+The same browser remembers this connection. On later visits, **Start sharing**
+offers **Apps / Windows** and **Screens** from the running App. In another browser,
+in a private window or after clearing site data, connect again. The same exact
+site already approved by App needs no repeat confirmation. The launcher
+shows the approved site; **×** beside its address revokes access. Approving a different site
+replaces the previous one and ends that site's App connections.
+To use your App with a friend's new public-invitation address, connect and approve
+that address again. Sharing-quality settings saved in App survive the address change.
**A page that opens does not guarantee a video connection.** Public invite gives
your room a temporary web address; picture and sound still travel between
@@ -121,6 +128,22 @@ participants. This mode has no media-server fallback, and the temporary address
has no uptime guarantee. All modes need a working UDP media path.
The [App guide](../../cmd/piik-app/README.md#modes) explains the connection setup.
+### Upgrading
+
+**Piik 2.0 and 1.x cannot be used together.** The App and the site it connects to
+need the same major version. Read the release notes on
+[GitHub](https://github.com/TNTcraftHIM/Piik/releases/latest) or
+[Gitee](https://gitee.com/TNTcraftHIM/Piik/releases) before upgrading.
+
+- **Local room or Public invite:** stop sharing, close the old App and extract
+ the complete new package. Start the new App and send a fresh invitation.
+- **An existing site:** the administrator updates the Server first, following
+ the [update guide](../operations/service-management.md). Then update the Apps
+ connecting to that site and refresh all sharing and viewing pages.
+
+Restarting an App or Server interrupts active shares. This upgrade does not
+change the room database schema.
+
## Share from an existing site
You need an existing HTTPS site running Piik Server, provided by a friend or
@@ -146,6 +169,28 @@ The site manages rooms and invitations; the App supplies native capture.
If its operator has enabled SFU forwarding, turn off **Privacy mode** before
sharing to allow that fallback. The project's online site uses P2P only.
+## Picture and sound settings
+
+Open **Sharing settings** below the picture to adjust picture and audio quality.
+
+- Choose a picture preset, or enter width and height under **Resolution limit**.
+ Piik preserves the source's proportions within that ceiling, including portrait
+ and ultrawide pictures.
+- Sliders cover 15–60 fps in steps of 5 and 2–12 Mbps in steps of 0.5. You can also enter an
+ **FPS limit** from 1 to 240 fps and a **Bitrate limit** from 0.1 to 240 Mbps.
+ Values outside those slider steps show **Custom**; **Use common range**
+ selects the nearest available slider value.
+ The frame-rate limit depends
+ on picture size: for example, 1080p240, 1440p144 and 4K60 are accepted requests.
+- Choose an audio preset, or enter an **Audio bitrate limit** from 6 to 320 kbps.
+
+The selectable range depends on picture size and the connected App. Picture and sound
+still adapt to the source, device and available bandwidth.
+
+Piik remembers your applied settings for the next visit. Browser-only use remembers
+them for the current site; opening Piik through an updated App also keeps them
+when your public-invite address changes.
+
## Add your voice
While sharing, select **Microphone** below the picture and
@@ -180,6 +225,12 @@ This excludes one process and its children. Other apps remain audible, including
those playing through other output devices.
It does not mute your Piik microphone or prevent speaker sound reaching a microphone.
+If a virtual microphone remains audible with Piik's microphone off, temporarily
+turn off **System sound** to compare. If the voice disappears, check the mixer's
+playback output and monitoring, or capture only the desired application's window
+audio. Excluding a voice app does not also exclude a separate mixer that plays
+your processed voice, even through a virtual output device.
+
While sharing, use **Sharing settings → Sound** to change system sound or the
excluded app without stopping the share. If the app closes or restarts, source
audio stops; refresh the application list and select it again.
@@ -197,6 +248,12 @@ sofa to send messages. Select a person on the sofa to open their reaction menu:
your own reactions appear above you; reactions sent to a friend fly towards them.
The chat window can be moved and resized without rearranging the page.
+Supported desktop browsers show **Always-on-top chat** in the window's top-right
+corner, so you can read messages while playing. Keep the Piik page open; closing
+the small window returns chat to the page. Use a windowed or borderless game;
+exclusive fullscreen may cover the chat. The window cannot show the game through
+its background or let mouse clicks pass through.
+
**On screen**, beside the message box or in the viewer's playback bar, shows new
messages over the picture. It is off by default and only changes your own view.
Open **Chat settings** in the chat window to adjust text size and opacity.
diff --git a/docs/guide/getting-started.zh-CN.md b/docs/guide/getting-started.zh-CN.md
index 04d6bc1a9..27da28b3b 100644
--- a/docs/guide/getting-started.zh-CN.md
+++ b/docs/guide/getting-started.zh-CN.md
@@ -89,25 +89,41 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
| 模式 | 什么时候用 | 需要知道 |
| --- | --- | --- |
-| **本地房间** | 朋友和你在同一个局域网。 | 出现多个「局域网邀请地址」时,选择与朋友同一网络的网卡和 IP。对方需要能连接到你的电脑,可以设置本地站点口令。 |
+| **本地房间** | 朋友和你在同一个局域网。 | 多网卡时展开「局域网邀请地址」,选择与朋友同一网络的网卡和 IP。对方需要能连接到你的电脑,可以设置本地站点口令。 |
| **公网邀请** | 朋友在别处,想临时发个邀请。 | 需要互联网连接;公网地址只在本次 App 运行期间有效。 |
-| **连接站点** | 你们已经有一个 Piik 站点。 | 输入站点地址,在原有分享页面使用 App 的采集能力。 |
本地房间和公网邀请可展开 **站点口令** 设置可选的访问口令。
-使用 **连接站点** 时,输入域名(例如 `demo.piik.tv`)或粘贴完整网址,然后点击 **进入 Piik**。
-省略前缀时自动使用 HTTPS;HTTP 站点请填写 `http://`。
-如果站点要求访问口令,在打开的站点页面输入。通过 App 打开过一次后,同一浏览器会记住这个入口。
-保持 App 运行并使用该站点配置,之后就能直接访问网址。点击 **开始分享**,
-在选择器里选 **程序 / 窗口** 或 **屏幕**,即可使用 App 采集。
-浏览器询问本地网络权限时,请允许访问。换浏览器、使用无痕窗口或清除站点数据后,
-需要重新从 App 进入一次;换用其他站点时,先在 App 中更新地址。
+**已经有 Piik 站点?** 保持 App 运行,打开站点,点击顶部的 **App**。
+在本机确认页核对网址,点击 **允许使用**。如果站点要求口令,在站点页面输入。
+浏览器询问本地网络权限时,请允许访问。启动页无需选择房间模式。
+也可以在 **开始分享** 的选源窗口里点击 **连接 Piik App**,连接后列表会直接显示 App 来源。
+连接失败时,App 按钮旁会提供 **重试** 和 **下载 App**;仍可继续使用浏览器分享和观看。
+
+同一浏览器会记住这次连接。之后直接访问站点,点击 **开始分享**,
+选择 **程序 / 窗口** 或 **屏幕**,即可使用正在运行的 App。
+换浏览器、使用无痕窗口或清除站点数据后,重新连接即可;App 已允许的同一站点无需再次确认。
+启动页显示已授权的站点,网址右侧的 **×** 可取消授权;允许新站点会替换旧站点,并结束旧站点的 App 连接。
+如要通过自己的 App 使用朋友的新公网邀请地址,需要重新连接并确认;App 保存的分享画质不受地址变化影响。
**网页能打开,不代表视频一定能连通。** 公网邀请为房间提供临时网页地址,
画面和声音仍靠参与者之间的连接传输。此模式没有媒体服务器兜底,
临时地址也不保证持续可用。所有模式都需要可用的 UDP 媒体路径。
具体连接方式见 [App 指南](../../cmd/piik-app/README.zh-CN.md#运行模式)。
+### 版本升级
+
+**Piik 2.0 与 1.x 不能混用。** App 与连接的站点需要使用同一大版本。
+升级前,请先查看 [GitHub](https://github.com/TNTcraftHIM/Piik/releases/latest) 或
+[Gitee](https://gitee.com/TNTcraftHIM/Piik/releases) 上的版本说明。
+
+- **本地房间或公网邀请:** 停止分享并关闭旧 App,完整解压新版程序包,
+ 启动后重新发送邀请链接。
+- **已有站点:** 管理员先按[更新指南](../operations/service-management.zh-CN.md)升级 Server,
+ 然后更新连接该站点的 App,并刷新所有分享与观看页面。
+
+重启 App 或 Server 会中断正在进行的分享。本次升级不变更房间数据库结构。
+
## 使用已有站点分享
需要先有一个运行 Piik Server 的 HTTPS 站点,可以使用朋友或管理员提供的地址。
@@ -129,6 +145,23 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
如果管理员已启用 SFU 转发,房主可在开始分享前关闭 **隐私模式**,允许使用这条兜底线路。
项目提供的在线版仅使用 P2P。
+## 画面与声音设置
+
+在画面下方打开 **分享设置**,调整画质和音频质量。
+
+- 可选画质预设,也可在 **分辨率上限** 中填写宽和高。画面在上限内保留来源比例,
+ 包括竖屏和超宽屏。
+- 滑块覆盖 15–60 帧(每步 5 帧)、2–12 Mbps(每步 0.5 Mbps)的常用范围;也可直接填写 1–240 帧的 **帧率上限**
+ 和 0.1–240 Mbps 的 **视频码率上限**。
+ 不在滑块档位上的数值显示为 **自定义**;点击 **返回常用范围**,使用最近的可用档位。
+ 帧率范围随画幅变化,例如可以请求 1080p240、1440p144 或 4K60。
+- 音频保留三档预设,也可填写 6–320 kbps 的 **音频码率上限**。
+
+可选范围会随画幅和连接的 App 变化。实际画质和声音仍会根据来源、设备与可用带宽调整。
+
+Piik 会记住已应用的设置,供下次使用。仅用浏览器时,设置保存在当前站点;
+通过新版 App 打开时,更换公网邀请网址也能保留设置。
+
## 加上自己的声音
分享时,点击画面下方的 **麦克风**,并允许访问。
@@ -156,6 +189,10 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
其他应用的声音仍会被分享,包括输出到其他播放设备的声音。
它不影响 Piik 麦克风,也不能消除扬声器被麦克风收音产生的回声。
+使用虚拟麦克风时,如果关闭 Piik 麦克风后仍能听到你的声音,可暂时关闭 **系统声音** 对照。
+若声音随之消失,请检查混音软件的播放输出与监听,或改为只分享目标应用的窗口音频。
+排除语音软件不会同时排除另一个混音软件;后者输出到虚拟播放设备的声音也可能被采集。
+
开播后,可在 **分享设置 → 声音** 中调整系统声音和排除的应用,无需停止分享。
所选应用关闭或重启后,来源声音会停止;请刷新应用列表并重新选择。
暂时关闭来源声音会保留排除选择;需要恢复完整的系统声音时,选择 **不排除**。
@@ -168,6 +205,10 @@ Windows 10 是否支持取决于系统更新与实际检测;未显示此选项
点击沙发上的人物可打开表情菜单:给自己发的表情出现在头顶,送给朋友的表情会飞向对方。
聊天窗口可以移动和缩放,不会挤动页面。
+支持的桌面浏览器会在窗口右上角显示 **置顶聊天窗口**,方便切到游戏时看消息。
+保持 Piik 页面打开;关闭小窗会把聊天收回页面。建议游戏使用窗口或无边框模式,
+独占全屏不保证显示置顶窗。小窗目前不支持透出游戏画面或鼠标穿透。
+
输入框旁和观众播放控制栏中的 **弹幕** 可以把新消息显示在画面上,默认关闭,只影响自己的观看。
打开聊天窗口中的 **聊天设置**,可以调整弹幕字号和不透明度。
系统视频全屏和画中画无法显示弹幕。
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index 7203d1270..3cc9d9c5e 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -25,6 +25,7 @@ collect a diagnostic report if the problem persists.
| Picture but no source sound | Unmute the video. The host should choose a source with shareable audio and enable sound in the source picker. Camera video and audio inputs are selected separately; see [audio settings](./getting-started.md#add-your-voice). |
| Cannot hear the host's voice | The host can enable **Microphone** below the picture, then check the selected input and its volume in **Settings → Sound**. Check the device connection and microphone permission. If the control is unavailable, update the App and refresh the page. |
| Screen sharing includes a voice app's audio | Windows App screen capture can [exclude application audio](./getting-started.md#exclude-a-voice-app-from-screen-audio). This does not remove echo from speakers picked up by a microphone. |
+| Virtual microphone has echo, or remains audible when muted in Piik | Temporarily turn off **System sound** to compare. A mixer's playback can enter source audio separately from Piik's microphone; check its [audio routing](./getting-started.md#exclude-a-voice-app-from-screen-audio). |
| Page opens but video will not connect | Use **Reconnect** if available, or refresh the viewing page. See [connection troubleshooting](#when-video-will-not-connect) if it still fails. |
| WebRTC or IP-leak protection is enabled | Check whether it blocks media connections using [browser WebRTC settings](#browser-webrtc-restrictions). |
| Connection fails on a campus or restricted network | Compare with a phone hotspot, then consider a [site with SFU forwarding](#campus-networks-and-strict-nat). |
@@ -43,7 +44,7 @@ checks, a copyable failure summary and browser diagnostics. The summary contains
the Web version, message and error code, without invitation links or source names.
- **App disconnected or request timed out:** check that the App is running and
- responding, open this site from the App, then refresh. Allow local network
+ responding, then click **App** at the top of the site to connect. Allow local network
access if the browser asks. A timeout alone does not establish a graphics fault.
- **App could not start the selected source:** try an ordinary window or screen,
then compare with the **Browser** source picker. On Windows, try **Auto** or
@@ -83,9 +84,9 @@ See [Server deployment](../operations/self-hosting.md) for the configuration.
## App windows or screens are missing
-Keep Piik App running on the computer where you are sharing. Open the current
-site from the App; for a hosted site, use the App's **Site** mode with that
-site's address. If the browser asks for local-network access, allow it, then
+Keep Piik App running on the computer where you are sharing. Click **App** at
+the top of the site, then confirm its address on the local approval page.
+If the browser asks for local-network access, allow it, then
refresh the source list. If access was previously blocked, review this site's
browser permissions first.
@@ -93,7 +94,7 @@ The source picker distinguishes these outcomes:
| Message | Next step |
| --- | --- |
-| Cannot connect to Piik App | Confirm the App is running and opened this site. Close unused Piik tabs using the App, then refresh the list; up to two pages can use the App's native capture or viewing at once. |
+| Cannot connect to Piik App | Confirm the App is running and this site is approved. Close unused Piik tabs using the App, then refresh the list; up to two pages can use the App's native capture or viewing at once. |
| Piik App and this page are incompatible | Update Piik App and reload the page. |
| Piik App capture is currently unavailable | Check the App diagnostics for capture or encoder availability. Review your encoding setting or use browser capture. |
| Could not read the screen and window list | Refresh to retry. If it keeps failing, export both the App and browser Debug reports. |
@@ -210,13 +211,19 @@ from a page that opens but reports **No media route available**.
1. **Viewers: confirm the link.** Ask the host to check that the App is running
and the computer is awake, then send the current invitation. Use a new link
after the App restarts; the old address may no longer work.
-2. **Hosts: read the terminal error.** Check that the complete App package was
- extracted. Save the full startup error and try again with **Debug launch**
+2. **Hosts: read the failure details.** Check that the complete App package was
+ extracted. Save the full error from the launch page or terminal, including
+ any detail after the exit code, and try again with **Debug launch**
enabled. The App retries early tunnel-process exits within its startup deadline.
3. **Compare networks.** Try a phone hotspot. Check whether DNS, a proxy or a
firewall blocks Cloudflare Tunnel; adjust only relevant rules. Ask the
administrator about a school or workplace network.
+For a terminal timeout mentioning `:7844`, check outbound connections from
+`cloudflared`, including proxy or game-accelerator rules.
+[Cloudflare's connectivity checks](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/troubleshoot-tunnels/connectivity-prechecks/)
+cover UDP and TCP on this port. This does not require opening an inbound port on your router.
+
If it keeps failing, you can share through the [online site or an existing site](./getting-started.md#share-from-an-existing-site).
Include the App version, failure time and [App diagnostic report](../../cmd/piik-app/README.md#diagnostics)
in feedback. If the unavailable address is a self-hosted site rather than the App's
@@ -234,7 +241,15 @@ If it recurs or never recovers, compare the following:
rate under **Settings → Picture**, and check CPU/GPU load while the game runs.
An affected viewer can compare another browser or a phone hotspot. Increasing
bitrate does not necessarily help.
-3. **Save reports from the affected period.** Enable diagnostics on the host and
+3. **Check the actual codec.** Open the host's **Show connection details** and
+ read **Video codec**. On Windows, Auto may select VP8 when H264 is unavailable,
+ its check cannot finish, or it cannot sustain the selected frame rate.
+ Software encoding competes with the game for CPU time. For **Browser** capture,
+ follow the [graphics checks](#h264-sharing-fails-on-windows), or compare the App's
+ window/screen capture. The App tries available hardware encoders independently.
+ VP8 alone does not establish a GPU fault; forcing H264 after a failed check may
+ make performance worse.
+4. **Save reports from the affected period.** Enable diagnostics on the host and
affected viewer pages before reproducing; App capture also needs an App report.
Try **Reconnect** if the picture does not recover. Export before refreshing.
@@ -253,6 +268,12 @@ Routers and providers differ in how they map addresses and admit incoming traffi
some combinations make direct connections difficult. Firewalls and browser
policies can also block media. The message alone does not identify the cause.
+STUN helps discover an address for direct connections; it does not relay the
+picture. An unreachable STUN service can therefore leave chat working while
+media fails. On self-hosted sites, administrators should check that `STUN_URLS`
+points to reachable UDP services; an HTTP reverse proxy does not forward STUN.
+Compare the diagnostic reports from both peers before assigning the cause.
+
Try these in order, stopping when the picture arrives:
1. **Retry the viewing page.** Choose **Reconnect** if it is available. If it is
@@ -295,6 +316,9 @@ explains discovery and connection checks in more technical detail.
If these steps do not help, collect a [Debug report](../../cmd/piik-app/README.md#diagnostics)
from the affected Viewer and, if possible, the Host for the same attempt. Include
the time, Piik version and network type when reporting the problem.
+For a self-hosted site using SFU, also include the Server report. Successful Host
+upload or subscription setup alone does not establish that the Viewer received
+and decoded the picture; the reports from both sides help locate the failing step.
### Browser WebRTC restrictions
@@ -326,8 +350,9 @@ same invitation works there, focus on the original network's restrictions.
For regular use, consider **self-hosting a Piik site with SFU media forwarding**.
This gives viewers a server path when direct connections fail. Follow the
[deployment guide](../operations/self-hosting.md#optional-media-fallback), including
-its public-address and UDP-port requirements. The Host can then open that site
-through the App's **Site** mode and send invitations from the new site.
+its public-address and UDP-port requirements. Keep Piik App running, open the new
+site in your browser and select **App** at the top to approve its local access.
+Send invitations generated by that site.
The public demo and App Public invite mode remain P2P-only. SFU also requires
usable UDP: when the network blocks UDP entirely, use another network or ask the
diff --git a/docs/guide/troubleshooting.zh-CN.md b/docs/guide/troubleshooting.zh-CN.md
index e69738f0f..ed19e6c89 100644
--- a/docs/guide/troubleshooting.zh-CN.md
+++ b/docs/guide/troubleshooting.zh-CN.md
@@ -25,6 +25,7 @@
| 有画面,没来源声音 | 先取消视频静音。房主应选择支持分享声音的来源,并在选源器中打开声音。摄像头画面与音频输入分别选择,见[声音设置](./getting-started.zh-CN.md#加上自己的声音)。 |
| 听不到房主说话 | 房主可在画面下方开启 **麦克风**,在 **设置 → 声音** 中检查输入设备和音量,并确认设备连接与麦克风权限。如控件不可用,请更新 App 并刷新页面。 |
| 分享时把语音软件的声音也传出去了 | Windows App 屏幕采集可选择[排除应用声音](./getting-started.zh-CN.md#分享屏幕时排除语音软件的声音);该功能不消除扬声器被麦克风收音产生的回声。 |
+| 虚拟麦克风有回声,或在 Piik 闭麦后仍能听到 | 暂时关闭 **系统声音** 对照。混音软件的播放输出可能通过来源音频进入分享,请检查[音频路由](./getting-started.zh-CN.md#分享屏幕时排除语音软件的声音)。 |
| 网页能打开,画面连不上 | **重新连接** 按钮可用时先点击,否则刷新观看页。仍然失败时,查看 [画面连接排查](#画面连接不上)。 |
| 开启了 WebRTC 或 IP 泄露保护 | 按[浏览器 WebRTC 设置](#浏览器限制了-webrtc)检查是否阻止了媒体连接。 |
| 校园网或受限网络连不上 | 先用手机热点对照,再考虑[启用 SFU 的自建站点](#校园网或严格-nat)。 |
@@ -41,7 +42,7 @@
电视下方的错误图标说明当前失败;点击可打开排查面板,查看建议、复制失败信息,
或开启网页诊断。复制内容包含网页版本、提示和错误编号,不包含邀请链接或来源名称。
-- **App 连接断开或请求超时**:确认 App 仍在运行且可以响应,从 App 打开当前站点后刷新。
+- **App 连接断开或请求超时**:确认 App 仍在运行且可以响应,点击站点顶部的 **App** 完成连接。
浏览器询问本地网络权限时允许访问。超时只说明请求未及时完成,不能据此认定显卡故障。
- **App 未能启动所选来源**:先换一个普通窗口或屏幕;再对照「浏览器」选源。
Windows 上可试「自动」或 VP8,后续按[显卡排查](#windows-上-h264-分享失败)操作。
@@ -72,15 +73,15 @@
## App 找不到窗口或屏幕
-在分享画面的这台电脑上保持 Piik App 运行,并从 App 打开当前站点。
-使用已部署的站点时,在 App 的 **站点** 模式中填写该站点地址。
+在分享画面的这台电脑上保持 Piik App 运行,点击站点顶部的 **App**,
+在本机确认页核对网址并允许使用。
浏览器询问本地网络权限时允许访问,再刷新来源列表;如果之前拒绝过,先检查当前站点的浏览器权限。
选源器会区分以下情况:
| 提示 | 处理方法 |
| --- | --- |
-| 未能连接 Piik App | 确认 App 正在运行,且通过 App 打开了当前站点。关闭其他正在使用 App 的闲置 Piik 标签页后刷新列表;最多两个页面可以同时使用 App 的原生采集或收看功能。 |
+| 未能连接 Piik App | 确认 App 正在运行,且当前站点已获得授权。关闭其他正在使用 App 的闲置 Piik 标签页后刷新列表;最多两个页面可以同时使用 App 的原生采集或收看功能。 |
| Piik App 与当前页面不兼容 | 更新 Piik App 并刷新页面。 |
| Piik App 采集功能当前不可用 | 查看 App 诊断信息中的采集和编码能力,检查编码设置,或使用浏览器采集。 |
| 未能读取 App 的窗口和屏幕列表 | 刷新重试;若持续失败,请导出同一次尝试的 App 和网页诊断报告。 |
@@ -179,11 +180,16 @@ App 的 **公网邀请** 通过 Cloudflare Tunnel 提供临时网页地址。
1. **观众先确认链接。** 请房主确认 App 仍在运行、电脑未休眠,并发送当前邀请。
App 重新启动后应使用新链接,旧地址可能失效。
-2. **房主查看终端原因。** 确认已完整解压 App 程序包;启动失败时,保留终端的完整错误,
- 开启 **诊断启动** 后再试。App 会在启动时限内重试提前退出的隧道进程。
+2. **房主查看错误详情。** 确认已完整解压 App 程序包;保留启动页面或终端的完整错误,
+ 包括退出码后的详细信息。开启 **诊断启动** 后再试。
+ App 会在启动时限内重试提前退出的隧道进程。
3. **对照网络。** 可用手机热点重试,检查 DNS、代理或防火墙是否阻止 Cloudflare Tunnel。
只调整相关规则;学校或公司的网络应联系管理员。
+如果终端中的超时错误带有 `:7844`,请检查 `cloudflared` 的出站连接,
+包括代理或游戏加速器的规则。[Cloudflare 连接检查](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/troubleshoot-tunnels/connectivity-prechecks/)(英文)
+说明了该端口的 UDP 和 TCP 要求,无需在路由器上开放入站端口。
+
反复失败时可先通过[在线版或已有站点](./getting-started.zh-CN.md#使用已有站点分享)分享。
反馈中请附上 App 版本、失败时间和 [App 诊断报告](../../cmd/piik-app/README.zh-CN.md#诊断)。
如果打不开的是自建站点而非 App 的临时地址,请联系该站点管理员检查服务。
@@ -197,7 +203,12 @@ App 的 **公网邀请** 通过 Cloudflare Tunnel 提供临时网页地址。
2. **一次调整一项。** 房主可在 **设置 → 画面** 中先试较低的分辨率或帧率,
检查游戏运行时 CPU/GPU 是否接近满载;受影响的观众可用另一浏览器或手机热点对照。
将码率调高不一定有帮助。
-3. **保留问题发生时的报告。** 在房主和受影响的观众页面开启诊断后重现,
+3. **查看实际编码。** 展开房主的 **显示连接详情**,查看 **视频编码**。Windows 上,
+ 「自动」选择 VP8 可能是 H264 不可用、未能完成检测,或检测未达到所选帧率;
+ 软件编码会与游戏争用 CPU。使用「浏览器」选源时,可按[显卡排查](#windows-上-h264-分享失败)
+ 检查图形设置,或对照 App 的窗口/屏幕采集。App 会独立尝试可用的硬件编码器。
+ 不要仅凭 VP8 判断显卡故障,也不必强制使用检测未通过的 H264。
+4. **保留问题发生时的报告。** 在房主和受影响的观众页面开启诊断后重现,
App 采集还需 App 报告。画面持续不恢复时可尝试 **重新连接**;请先导出报告再刷新页面。
如果分享已自动停止,查看电视下方的具体原因,并按[分享启动排查](#选源后无法开始分享)操作;
@@ -212,6 +223,11 @@ NAT(网络地址转换)让多台设备共用一个上网地址。不同路
外部数据进入的规则不同,有些组合会让设备难以直连。防火墙和浏览器策略也可能阻止媒体连接,
仅凭这条提示还不能确定具体原因。
+STUN 帮助设备发现用于直连的地址,不负责转发画面。STUN 服务不可达时,
+也可能出现聊天正常、媒体连不上的情况。自部署管理员应检查 `STUN_URLS`
+是否指向可达的 UDP 服务;HTTP 反向代理不能转发 STUN。
+请结合双方同一次尝试的诊断报告判断原因。
+
可以依次尝试,画面恢复后就不必继续:
1. **重新尝试观看连接。** 如果 **重新连接** 可用,先点击;按钮不可用或没有效果时,
@@ -242,6 +258,8 @@ NAT(网络地址转换)让多台设备共用一个上网地址。不同路
仍然失败时,收集出现问题的观众端与房主端同一次尝试的
[诊断报告](../../cmd/piik-app/README.zh-CN.md#诊断),反馈时附上发生时间、Piik 版本和所用网络类型。
+自部署站点使用 SFU 时,也请附上服务端报告。房主上传成功或订阅建立,并不代表观众已经收到并解码画面;
+两端报告能帮助判断具体卡在哪一步。
### 浏览器限制了 WebRTC
@@ -267,7 +285,8 @@ Piik 通过 WebRTC 传输画面和声音。禁用 WebRTC 或禁止未经代理
经常需要使用的话,建议考虑 **自建 Piik 站点并启用 SFU 媒体转发**,让直连失败时有服务器路线。
按[部署指南](../operations/self-hosting.zh-CN.md#可选启用媒体兜底)配置公网地址并放行所需 UDP 端口。
-房主随后可通过 App 的 **站点** 模式打开新站点,并发送该站点生成的邀请链接。
+保持 Piik App 运行,用浏览器打开新站点,点击顶部的 **App** 完成本机授权,
+再发送该站点生成的邀请链接。
公开 Demo 和 App 的公网邀请模式只有 P2P。SFU 同样需要 UDP 可用;如果网络完全禁止 UDP,
仍需换网络或联系管理员放行,单独搭建服务器不能解除这类限制。
diff --git a/docs/operations/github.md b/docs/operations/github.md
index 6adfd763d..a2eecdb9b 100644
--- a/docs/operations/github.md
+++ b/docs/operations/github.md
@@ -40,16 +40,19 @@ 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
+For a complete package rehearsal, dispatch with `app_checks: true` and an
explicit `candidate_version` such as `v1.0.0`. This builds genuine Server and
native App artifacts under one revision and version without tagging or
publishing. Prepare its [release-note section](../standards/versioning.md#release-notes)
-and inspect the generated preview in the run summary. For a local preview, run
-`node scripts/release-notes.mjs v1.0.0 FULL_SOURCE_SHA` from the checkout with
-full history and tags. Download that run's artifacts and pass their directory, version and
-full revision to `scripts/publish-release.mjs --dry-run` (put the flag after
-the three positional arguments). An omitted candidate version keeps the usual
-development/exact-tag identity.
+in the candidate PR and review that rendered body; branch checkpoints need no
+release copy. The publishing run generates the final notes from accepted squash
+commits. To preview that aggregate locally after integration, run
+`node scripts/release-notes.mjs VERSION FULL_SOURCE_SHA` from the checkout with
+full history and tags. Download the candidate run's artifacts and pass their
+directory, version and full revision to `scripts/publish-release.mjs --dry-run`
+(put the flag after the three positional arguments). This validates package
+identity and checksums without publishing or aggregating branch commit messages.
+An omitted candidate version keeps the usual development/exact-tag identity.
Enable GitHub's built-in release immutability for formal publication where
available. Upload all assets to the draft before publishing. A failed upload can
@@ -80,19 +83,34 @@ Image checks, version identity and retries follow
## Gitee Download Mirror
[TNTcraftHIM/Piik](https://gitee.com/TNTcraftHIM/Piik) is a public README/Release
-mirror. Source, documentation and issues stay on GitHub. The publishing job uses
-the repository Actions secret `GITEE_TOKEN`, which is configured; rotate it there
+mirror. Source, documentation and issues stay on GitHub. The **Gitee mirror**
+workflow uses the repository Actions secret `GITEE_TOKEN`, which is configured; rotate it there
when needed. Never put it in source, a remote URL or a distributed package.
GitHub publication continues to use the workflow's scoped token.
-After GitHub publication, the same job runs `scripts/mirror-release.mjs` on the
-original artifacts. Upload failure leaves the Gitee release marked as a preview.
-Rerun the failed publishing job to verify existing files and upload only missing
-ones. GitHub's published release remains unchanged. Both publishers reject
-changing a published package; use a corrected new version if its bytes are wrong.
+After a successful main-push CI run, **Gitee mirror** downloads that run's original
+artifacts and runs `scripts/mirror-release.mjs`. It skips runs that did not publish
+the current GitHub release. GitHub publication and **Website** do not depend on
+mirror success. A mirror failure stays visible in its own failed workflow; an
+unfinished Gitee release remains a preview.
-For a local retry, use the release revision's scripts and original complete build
-output retained outside the checkout. Earlier versions can have a different
+Rerun **Gitee mirror** to verify existing files and upload only missing ones.
+For a separate retry, dispatch it on `main` with the **original CI run ID**:
+
+```sh
+gh workflow run gitee-mirror.yml --repo TNTcraftHIM/Piik --ref main -f run_id=RUN_ID
+```
+
+The source must be a completed main-push run of this repository's CI workflow,
+matching the latest published GitHub release. Manual recovery also accepts a
+legacy CI run that failed after GitHub publication. The workflow executes trusted
+main tooling and treats downloaded artifacts only as data. Old retries cannot
+complete a mirror for a superseded release. Both publishers reject changing a
+published package; use a corrected new version if its bytes are wrong.
+
+For a local retry, first let any active mirror workflow finish. Use the release
+revision's scripts and original complete build output retained outside the
+checkout. Earlier versions can have a different
package set. While the publishing run's artifacts are available, download all packages
into one directory (replace `RUN_ID`, `FULL_SOURCE_SHA` and `VERSION`):
@@ -130,4 +148,6 @@ history. The upload script does not itself delete older attachments.
- [Protected branch availability](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)
- [Workflow concurrency and queued runs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-queueing-multiple-pending-runs)
+- [Workflow completion triggers and trust boundaries](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run)
+- [Downloading original artifacts from another run](https://github.com/actions/download-artifact#download-artifacts-from-other-workflow-runs-or-repositories)
- [Immutable release workflow](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases)
diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md
index 64389b11d..5078b43cb 100644
--- a/docs/operations/self-hosting.md
+++ b/docs/operations/self-hosting.md
@@ -1,6 +1,6 @@
# Deploy Piik Server
-English · [简体中文](./self-hosting.zh-CN.md) · [Documentation](../README.md)
+English · [简体中文](./self-hosting.zh-CN.md) · [Documentation](../guide/README.md)
Piik Server packages the web interface, room management and optional media
forwarding in **one server binary**. Extract and run it; room data is stored in SQLite.
@@ -144,7 +144,8 @@ configuration uses P2P media, so participants need a usable UDP path.
Add `SFU_UDP_PORT=7882` to `.env`, allow UDP 7882, and restart Piik to enable
automatic SFU fallback. When the server is behind NAT, also set `SFU_PUBLIC_IP`
-to its reachable public IPv4 address. The same binary provides the fallback.
+to its reachable public IPv4 address and forward the same external and internal
+UDP port as `SFU_UDP_PORT`. The same binary provides the fallback.
Allow server forwarding capacity and outbound bandwidth for Viewers actually
using the SFU.
The Host must turn off **Privacy mode** before sharing to allow this route.
diff --git a/docs/operations/self-hosting.zh-CN.md b/docs/operations/self-hosting.zh-CN.md
index f91fa1cfc..ef3116945 100644
--- a/docs/operations/self-hosting.zh-CN.md
+++ b/docs/operations/self-hosting.zh-CN.md
@@ -1,6 +1,6 @@
# 部署 Piik Server
-[English](./self-hosting.md) · 简体中文 · [文档导航](../README.md)
+[English](./self-hosting.md) · 简体中文 · [文档导航](../guide/README.zh-CN.md)
Piik Server 将网页、房间管理和可选的媒体转发打包在**一个服务端程序**中。
解压后即可运行,房间数据保存在 SQLite 中。
@@ -130,6 +130,7 @@ TCP 8787 仅供本机反向代理访问。STUN 域名需要直接解析到服务
在 `.env` 中添加 `SFU_UDP_PORT=7882`,放行 UDP 7882,再重启 Piik,即可启用自动 SFU 兜底。
如果服务器处于 NAT 后方,还需将 `SFU_PUBLIC_IP` 设置为外部可达的公网 IPv4 地址。
+公网和内部映射的 UDP 端口都应与 `SFU_UDP_PORT` 一致。
这项功能由同一个服务端程序提供。
服务器需要为实际经由 SFU 观看的观众承担转发负载和出口带宽。
房主需要在开始分享前关闭 **隐私模式**,才会允许使用这条线路。
diff --git a/docs/operations/service-management.md b/docs/operations/service-management.md
index b3560cf81..7b28ddcc1 100644
--- a/docs/operations/service-management.md
+++ b/docs/operations/service-management.md
@@ -1,6 +1,6 @@
# Service Management
-English · [简体中文](./service-management.zh-CN.md) · [Documentation](../README.md)
+English · [简体中文](./service-management.zh-CN.md) · [Documentation](../guide/README.md)
For first installation, use the [short self-hosting guide](./self-hosting.md).
This reference covers a persistent Linux service and container maintenance.
diff --git a/docs/operations/service-management.zh-CN.md b/docs/operations/service-management.zh-CN.md
index 0adc90ae9..752aeef0f 100644
--- a/docs/operations/service-management.zh-CN.md
+++ b/docs/operations/service-management.zh-CN.md
@@ -1,6 +1,6 @@
# 服务管理
-[English](./service-management.md) · 简体中文 · [文档导航](../README.md)
+[English](./service-management.md) · 简体中文 · [文档导航](../guide/README.zh-CN.md)
首次安装请从[部署指南](./self-hosting.zh-CN.md)开始。本页介绍 Linux 常驻服务与容器维护。
diff --git a/docs/operations/website.md b/docs/operations/website.md
index cbd8684b1..9899e2951 100644
--- a/docs/operations/website.md
+++ b/docs/operations/website.md
@@ -24,6 +24,21 @@ GitHub Pages can serve the complete website: it hosts
The demo also needs the Go service for room authority, WebSocket signaling and
UDP STUN, so it runs on the separate US server rather than Pages.
+### Managed Hosting Assessment
+
+Reviewed 2026-10-07. Keep the current deployment for this candidate. A managed
+container can reduce host administration, but must preserve public UDP, stable
+room authority and SQLite storage. [Fly.io supports public UDP](https://docs.fly.io/networking/udp-and-tcp)
+with a dedicated IPv4 address and a specific UDP bind address; external and
+internal UDP ports must match. Its [dedicated IPv4 charge](https://docs.fly.io/about/pricing)
+is $2/month before compute, storage and transfer. This is a possible deployment
+target, not an established cost saving over the current server.
+
+[Cloudflare Containers disks are ephemeral by default](https://developers.cloudflare.com/containers/faq/#is-disk-persistent-what-happens-to-my-disk-when-my-container-sleeps).
+Snapshots or object-backed storage are not a drop-in replacement for the current
+live SQLite contract. No provider migration is accepted; compare the complete
+cost and verify storage, UDP and restart behavior before changing the Demo.
+
## Public Demo Configuration
The chosen demo allows entry without a site password and provides P2P sharing
@@ -161,7 +176,8 @@ Verify the latest redirects and package checksums after releases.
4. Accepted main pushes run CI first; success triggers **Website**, which checks
out that CI run's exact commit. Manual dispatch uses its selected `main` commit.
Before publishing, both paths skip a build that is no longer current main;
- dispatch again for the latest commit when needed. Download links resolve through each provider, so
+ dispatch again for the latest commit when needed. The separate Gitee mirror
+ workflow does not gate Website deployment. Download links resolve through each provider, so
publishing a package or retrying its mirror needs no link refresh.
Only `build/site/` is uploaded; website work does not package App/Server.
Verify HTTPS, both languages, relative assets and per-platform download links.
diff --git a/docs/project-memory.md b/docs/project-memory.md
index d49572375..3501fee71 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -1,6 +1,6 @@
# Project Memory
-Last updated: 2026-10-06
+Last updated: 2026-10-14
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
@@ -9,11 +9,12 @@ mobile Browsers can also host. It is not a
public broadcast service. Hosted and App run one shared Go core
([ADR-0012](./adr/0012-shared-go-backend-core.md)); Node and Vite build the
Browser UI and are not a runtime. Source includes a cross-platform App that
-runs that same core in a self-contained Local deployment or opens one configured
-Site through the system Browser. Its single Go process presents Local, temporary
-public-link, and saved-Site choices on every launch, starts one process-level
-loopback capability service, owns the Local room authority in-process when
-selected, and owns the explicit native media path chosen in the same Web UI.
+runs that same core in a self-contained Local deployment or supplies native
+capability to one approved Site through the system Browser. Its single Go
+process starts the loopback capability service before room selection. The
+persistent launcher offers Local and temporary public-link choices and manages
+the Site's explicit local approval. Site use can remain on standby without a
+Local room service; native media still belongs to the participant's Web UI.
Room source and media implementation are independent: a topology may mix
Browser and Native peers without changing participant, signaling, capacity, or
route identity. Local is LAN-first; explicit one-link mode exposes that same
@@ -92,7 +93,9 @@ implementation and routine UI detail.
periodic rebalance is accepted.
- Browser video uses `motion`; each share chooses H.264 with VP8 fallback through
an actual sender probe unless the Host explicitly selects VP8 or H264. Screen
- audio uses 64/128/192 kbps ceilings with 128 default.
+ audio retains its compact presets and 128 kbps default. The 2.0 contract
+ separates numeric bitrate requests from those UI shortcuts; implementation
+ limits and admission remain in the media-quality owner.
- Viewer playback uses one native video element. Presentation and recovery are
fenced by current route/media identity and current-frame proof; hidden-page
time alone is not media-failure evidence or a keepalive guarantee.
diff --git a/docs/research/browser-local-encoding-pool.md b/docs/research/browser-local-encoding-pool.md
index 873c44832..9a253b2f3 100644
--- a/docs/research/browser-local-encoding-pool.md
+++ b/docs/research/browser-local-encoding-pool.md
@@ -101,6 +101,31 @@ around fallback was 271 ms for H264 and 80 ms for VP8. Source-wide fallback
therefore has a measurable handoff cost, even without a resolution drop or
connection failure. This does not establish low-end hardware capacity.
+An unmatched negotiated codec is instead connection-local. A Chrome 154
+late-Viewer control reproduced all members losing pooling when only the new
+Viewer advertised different codec parameters. Scoped fallback now preserves
+the first Viewer's producer and delivers fresh pictures to both Viewers in
+VP8 1080p and H264 4K controls. Unit counterfactuals cover admission and active
+codec changes; producer failure retains its existing source-wide fallback.
+These controls establish membership isolation, not enforcement of physical
+decoder limits or the cause of field quality fluctuations.
+
+### Local Connection Failure Control
+
+On 2026-10-08, Windows headless Chrome 154 ran the actual `HostPeer` and pool
+with a moving 640x360 VP8 source. mDNS host candidates supported one producer
+and two receivers. Discarding only the producer's ICE candidates triggered its
+eight-second timeout; both existing P2P connections then delivered 30 ordinary
+decoded frames each, retaining the source and no producer groups.
+
+This loopback-origin diagnostic does not test public-origin permission,
+Chrome/Firefox LAN interoperability or public-path quality. In
+[#465](https://github.com/TNTcraftHIM/Piik/issues/465), the successful repeat
+changed permission and IP masking together. Neither is an isolated cause;
+[HTTP App discovery](https://developer.chrome.com/blog/local-network-access)
+and [WebRTC connectivity](https://chromestatus.com/feature/5065884686876672)
+need separate evidence. This control is not a release acceptance gate.
+
### Balanced Startup And Recovery
Serial Chrome 152 checks on 2026-09-25 reproduced two composition defects in
@@ -661,9 +686,16 @@ npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --background
npx tsx scripts/browser-local-pool-probe.ts carrier --display --quiet-start --1080 --h264 --av --lifecycle
npx tsx scripts/browser-local-pool-probe.ts ordinary --display --quiet-start --1080 --av --lifecycle
npx tsx scripts/browser-local-pool-probe.ts carrier --display --1080 --h264 --av --network --auto --rate=400000 --preference=balanced
+npx tsx scripts/browser-local-pool-probe.ts carrier --display --size=3840x2160 --h264 --single --lifecycle
+npx tsx scripts/browser-local-pool-probe.ts carrier --size=2160x3840 --h264 --relay --late
```
Add `--h264`, `--single` or `--late` for the corresponding case. Set
+`--size=WIDTHxHEIGHT` for a numeric source/quality ceiling within current 30 fps
+capture bounds; this also requests that window size for real tab capture.
+Chrome's content area and capture toolbar can make the actual surface smaller.
+The pool check expects one output owner per compatible allocation, not one
+owner for children with different bandwidth budgets. Set
`--network --auto --short-pulse` for a 45-second warmup, one-second constraint
and 40-second recovery instead of the ordinary 14-second constraint. Set
`CHROME_PATH` for another installed Chromium binary. `--auto` extends the
diff --git a/docs/research/browser-screen-audio-quality.md b/docs/research/browser-screen-audio-quality.md
index 1aaad6a70..3c056316d 100644
--- a/docs/research/browser-screen-audio-quality.md
+++ b/docs/research/browser-screen-audio-quality.md
@@ -55,6 +55,96 @@ weak-network behavior, real-game A/V synchronization, or mobile playback.
- Lossy Wi-Fi/cellular concealment and Opus FEC behavior with current options.
- macOS, Android, and iOS capture/playback behavior.
+## Numeric Bitrate Assessment
+
+Checked 2026-10-09 for the 2.0 candidate. The published 1.x product retains its
+preset contract. Candidate numeric requests and supported limits are owned by
+[media quality](../standards/media-quality.md#screen-audio); these measurements
+do not establish audible improvement or accept a public release.
+
+For scope review on 2026-10-11, [RFC 7587](https://www.rfc-editor.org/rfc/rfc7587.html#section-3.1.1)
+recommends 64..128 kbps for fullband stereo music with 20 ms frames, and
+[LiveKit's high-quality stereo preset](https://github.com/livekit/client-sdk-js/blob/main/src/room/track/options.ts)
+uses 128 kbps. These support retaining the current default; they are neither
+codec maxima nor proof of quality for every source. Higher custom settings
+remain subject to the measured packet and receiver bounds below.
+
+The existing Go Opus encoder accepted live 128/192/256/320/96/128 kbps changes
+without replacement. A 48 kHz stereo tone/noise input continued decoding into
+960 samples per channel; the existing deterministic tests also verify stereo
+separation at 256/320 kbps and rejection of invalid updates without replacing
+the last setting. This establishes encoding continuity, not audible benefit.
+
+A Chrome 152 loopback used the actual sender primitive, generated stereo audio,
+and decoded PCM measurement. The same PeerConnections remained connected while
+changing those ceilings. Received payload tracked approximately 96/128/192/256/320
+kbps, with decoded samples advancing and nonzero waveform energy. This is a
+local Browser check, not relay/SFU, mobile, impaired-network or listening acceptance.
+
+Two controls constrain the replacement design:
+
+- Chrome retained `maxaveragebitrate=192000` in the remote answer and sender
+ codec parameters, yet later `setParameters()` calls produced approximately
+ 256/320 kbps. Repeating with a 510000 answer also passed. This scoped runtime
+ counterexample does not change [RFC 7587's receive-limit semantics](https://www.rfc-editor.org/rfc/rfc7587.html#section-7.1).
+ A numeric sender setting must respect the admitted receiver limit; do not
+ assume SDP presence or successful parameter readback proves enforcement.
+- With native 20 ms frames, the tone/noise check produced maximum Opus payloads
+ of 641 bytes at 256 kbps and 801 at 320 kbps. At 510 kbps, all 150 frames
+ produced RTP packets larger than a 1200-byte packetizer budget. Pinned
+ [Pion's Opus payloader](https://github.com/pion/rtp/blob/v1.10.5/codecs/opus_packet.go)
+ keeps each Opus payload intact. This is a packet-size result, not measured
+ network loss; accepting the codec's maximum requires a transport/packet-duration
+ decision. Do not equate the codec range with every route's safe operating range.
+- Silence/tone/noise/impulse transitions expose a separate peak: the former
+ gopus v0.1.1 encoder produces a 1234-byte payload at 320 kbps. Its single-stream
+ CELT path also ignores the output budget and fails with a smaller buffer.
+ [gopus v0.2.2](https://github.com/thesyncim/gopus/tree/v0.2.2) honors that budget;
+ the adapter now reserves packet overhead at the encoding boundary using
+ [Opus's instantaneous size limit](https://opus-codec.org/docs/opus_api-1.6/group__opus__encoder.html).
+ The retained regression decodes complete 20 ms frames across transients and
+ live bitrate changes. This does not diagnose any field interruption or change
+ the average bitrate controls, stereo format or route model.
+
+The [media extension proposal](./native-client-media.md#audio-and-ui-proposal)
+owns the UI and contract rationale. The candidate enforces a Browser sender's
+negotiated Opus limit, and admits unchanged Native/SFU forwarding only when the
+receiver's declared limit accommodates the full shared format. A codec-valid
+native setting beyond the current sender ceiling remains an operational failure.
+Browser and App preference stores round-trip custom numeric values; those stores
+were not part of published v1.9.2, so no legacy preset migration is required.
+
+The retained SFU gate passed for both Browser and Windows native publication:
+live 320/96 kbps changes retained the same subscription, with advancing decoded
+audio energy and video frames. Video profile changes, reload, recovery and
+retirement also passed; the Browser run measured stereo separation. An ordinary
+Browser relay forwarded received tracks to two children at 320 kbps, with
+advancing decoded audio and successful cleanup. Direct Browser controls keep
+requested 256/320 kbps below an explicit 192 kbps receive limit, while a
+510 kbps receiver permits both rates. The full Browser room also retains both
+receiving tracks and advancing decoded audio/video through 320/96/320 kbps edits;
+only the limited child's sender is capped at 192 kbps.
+Native and SFU adapter controls reject an insufficient answer before binding it.
+In a full-UI SFU-only room, that rejection exhausts only the limited Viewer's
+route and retires its connection. The Host, healthy sibling and subsequent
+capable Viewer continue; the service Debug event retains the receive-limit
+cause. Stop retires the remaining media. These are controlled SDP-limit checks,
+not physical low-capability decoder measurements.
+A native real-Opus control through
+6 Mbps -> 500 kbps -> 6 Mbps shaping retains decoded audio during live
+320/96/320 kbps changes, with sustained high video after the shared
+[probe measurement and video-budget repairs](./node-local-media-design.md#send-queue-recovery).
+These are local runtime checks; physical networks, mobile playback and listening
+remain open.
+
+RFC 7587 gives an omitted receive bitrate a mode-dependent default, not an
+unrestricted value, and asks receivers to ignore out-of-range preferences.
+All candidate Piik receivers therefore declare the full Opus range explicitly,
+including the App's local preview bridge through the same Browser answer helper.
+Browser sender enforcement uses [libwebrtc's existing default calculation](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_coding/codecs/opus/audio_encoder_opus.cc)
+when a peer omits a valid limit. Unchanged forwarding requires an explicit
+sufficient limit; it cannot turn down the shared source for one receiver.
+
## Primary Sources
- [Screen Capture](https://www.w3.org/TR/screen-capture/)
diff --git a/docs/research/camera-and-microphone.md b/docs/research/camera-and-microphone.md
index 9d5ea5225..b6d6eca69 100644
--- a/docs/research/camera-and-microphone.md
+++ b/docs/research/camera-and-microphone.md
@@ -124,6 +124,28 @@ ordinary invitation in another browser tab. Use headphones for same-device
microphone tests. Browser-emulated mobile layout and synthetic devices do not
establish actual iOS/Android permission, orientation, background or audio behavior.
+## Phone Microphone Assessment
+
+The proposed phone link would supply one Host audio input, not give Viewers a
+room-voice permission. Web Audio can mix a received `MediaStream` into the
+Browser Host's existing output. The native mixer instead consumes fixed PCM
+from local capture processes; it has no authenticated remote-input boundary.
+Room signaling authorizes assigned parent/child connections, so an ordinary
+Viewer invitation must not become permission to publish audio into the Host.
+
+A coherent implementation therefore needs a Host-scoped, revocable pairing
+grant, one incoming audio connection and a decoded/resampled input for native
+mixing. The existing Host share must own that input's mute, replacement and
+retirement; input loss must leave healthy picture/source audio running. Phone
+capture still requires a secure origin and explicit microphone permission.
+The transport adds latency, and physical phone echo/background/lock behavior
+is not established by Browser emulation or a loopback audio test.
+
+This is a source/API assessment, not an implemented or device-verified feature.
+Keep it outside the current candidate until a concrete use case justifies that
+pairing and input ownership. An audio device already exposed by the operating
+system can use Piik's existing microphone selector without this extension.
+
## Primary Technical References
- [Google Meet audio controls](https://support.google.com/meet/answer/10409699?hl=en)
diff --git a/docs/research/cross-platform-client-runtime.md b/docs/research/cross-platform-client-runtime.md
index aee09a9f6..58aa94661 100644
--- a/docs/research/cross-platform-client-runtime.md
+++ b/docs/research/cross-platform-client-runtime.md
@@ -53,9 +53,10 @@ The App and Hosted Piik run the same server core, so the local package is
one executable carrying the built Browser assets plus the optional capture and
tunnel sidecars; [the App README](../../cmd/piik-app/README.md) owns
that layout. In Local mode the App runs the server in its own process with
-explicit local configuration. In Site mode it starts no server, and the system
-Browser opens the saved Site while the same process remains its loopback
-native-media owner. The system Browser remains the UI.
+explicit local configuration. While waiting for an approved Site it starts no
+room server; the same process remains the Site's loopback native-media owner.
+The persistent launcher owns local approval and room startup independently.
+The system Browser remains the UI.
Official Go process APIs require every started child to be waited and provide a
bounded `WaitDelay` for cancellation and stuck I/O. That guidance now covers the
@@ -167,9 +168,11 @@ the existing 30-second startup budget. This affects HTTP/WebSocket control only;
media routes, tunnel identity and ordered shutdown keep their existing owners.
DNS failures, API rejection and networks blocking both transports can still fail.
The preceding Quick Tunnel allocation is a single HTTPS request with a 15-second
-dependency timeout; failure exits before transport fallback applies. The App's
-pre-readiness exit message and exit code do not identify that cause. Match the
-same run's `public-tunnel` dependency diagnostics before classifying a report.
+dependency timeout; failure exits before transport fallback applies. The exit
+code alone does not identify that cause. An abnormal exit now retains the latest
+sanitized dependency error or plain stderr detail in the existing failure result.
+This is the last reported observation, not a classification or a complete log;
+match the same run's `public-tunnel` diagnostics when the detail is insufficient.
Piik retries a child that exits before readiness, up to four starts with a
one-second pause between starts, all within the same 30-second acquisition
deadline. The previous child, output readers and temporary config retire before
@@ -190,6 +193,21 @@ own the dependency behavior. An early-exit fixture confirms fourth-start recover
and bounded exhaustion; these checks do not identify a reporter's API or network
failure or guarantee four starts when earlier attempts consume the deadline.
+A 2026-10-08 Windows check against a deliberately refused local allocation API
+confirmed that cloudflared 2026.8.3 emits its final allocation error as plain
+stderr even with `--output json`. A JSON-only error reader therefore misses it.
+The process boundary retains that detail without changing retries or shutdown;
+normal cancellation still stays clean, and late cleanup cannot erase an already
+observed failure. The launcher applies its existing redaction and display limit.
+
+The public App log in [#452](https://github.com/TNTcraftHIM/Piik/issues/452),
+reviewed on 2026-10-07, records a QUIC timeout followed by a TCP port-7844
+connect timeout within that deadline. Thus fallback ran; the report does not
+establish a local port-80/443 collision. Its accelerator dependency remains a
+network-path hypothesis requiring the same endpoint with/without the accelerator.
+The attachment contains no capture start, so it cannot diagnose the separately
+reported H.264 failure.
+
[HTTP 1033](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1033/)
means Cloudflare cannot find a healthy tunnel connector. App readiness requires
both the assigned origin and a registered connection; later reconnection belongs
diff --git a/docs/research/cross-restart-room-recovery.md b/docs/research/cross-restart-room-recovery.md
index 7a5c833d0..1d13677c9 100644
--- a/docs/research/cross-restart-room-recovery.md
+++ b/docs/research/cross-restart-room-recovery.md
@@ -101,6 +101,24 @@ compatibility path. The completed cutover is retained in Git; current
7. Surviving Host/Viewer tabs reauthenticate and rebuild a fresh route; no test
labels that bounded rebuild as uninterrupted media.
+### SQLite I/O Failure Boundary
+
+On 2026-10-07, the current pure-Go driver was exercised in an isolated Linux VM
+with 600 synthetic rooms. Syscall fault injection rejected journal/database
+`pwrite64` and `fsync` during commit and rollback, including spilled dirty pages
+and sustained failures. Rejected writes did not become accepted authority;
+while the fault persisted, further writes failed. After removing the fault,
+reads, a new mutation and reopening the database retained the expected rows.
+Read-only, page-capacity and deferred-constraint failures are also covered by
+the ordinary room tests. This is not power-loss or filesystem-durability proof.
+
+SQLite can roll back automatically after an I/O or capacity error, so a separate
+application transaction flag cannot reliably describe it. The owner now issues
+one best-effort rollback on failure, preserves the original error and adds no
+retry/reopen mechanism. This follows [SQLite's transaction error guidance](https://sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction);
+the experiment used [strace syscall fault injection](https://man7.org/linux/man-pages/man1/strace.1.html)
+on disposable files, not production data.
+
## Primary Sources
- [Node.js SQLite](https://nodejs.org/docs/latest-v24.x/api/sqlite.html)
diff --git a/docs/research/media-fidelity.md b/docs/research/media-fidelity.md
index 2f95f1a99..50c8118d1 100644
--- a/docs/research/media-fidelity.md
+++ b/docs/research/media-fidelity.md
@@ -1,6 +1,6 @@
# Media Fidelity
-Reviewed 2026-10-03. [Media quality](../standards/media-quality.md) owns behavior;
+Reviewed 2026-10-13. [Media quality](../standards/media-quality.md) owns behavior;
[TODO](../todo.md) owns remaining work. These synthetic checks do not establish
all-device acceptance or full HDR support.
@@ -9,8 +9,8 @@ all-device acceptance or full HDR support.
Browser display capture requests two channels without speech processing. The
Host mixer uses a stereo Web Audio destination; Native capture and mixing use
48 kHz, two-channel PCM16. Native Opus uses the audio application mode. Browser,
-Native and SFU receivers request stereo. The existing 64/128/192 kbps choices
-are ceilings, not a lossless-audio promise.
+Native and SFU receivers request stereo. Audio settings are bitrate ceilings,
+not a lossless-audio promise; the media-quality owner defines the current controls.
`opus/48000/2` alone does not prove stereo content: Opus permits mono packets
under that declaration. [RFC 7587](https://www.rfc-editor.org/rfc/rfc7587.html#section-7)
@@ -169,3 +169,41 @@ Full HDR requires a separate end-to-end decision for bit depth, codec support,
color metadata, decoding and mixed Viewer capabilities. It is not a quality
dropdown addition. Preserve ordinary SDR routing and existing compatibility
with HDR-to-SDR; no full-HDR or surround implementation is claimed.
+
+## 2.0 Fidelity Reassessment
+
+Reviewed 2026-10-13 after the owner reopened HDR and multichannel consideration.
+Changing Piik's signaling contract does not by itself extend WebRTC codecs,
+capture formats or decoded outputs. Numeric quality limits and media formats
+have different compatibility boundaries.
+
+| Capability | Current evidence and additional work |
+| --- | --- |
+| Mono/stereo | Already carried by the existing Opus track, including microphone mixing, relay and SFU. Retain decoded-channel verification; a channel selector is unnecessary for these inputs. |
+| HDR source viewed as SDR | Windows native capture and thumbnails use the existing tone mapper. Physical HDR display acceptance remains open. Browser-owned capture may already have clipped highlights before Piik receives a frame. |
+| End-to-end HDR | Needs intact high-bit-depth capture, a supported HDR codec, color metadata and compatible decoding through every relay/output. Current native output uses 8-bit H.264/VP8. Mixed SDR viewers need a defined conversion owner; signaling an HDR flag cannot provide this. |
+| 5.1/7.1 surround | Needs channel-layout-preserving capture/mixing, multistream encoding, receiver admission and a stereo downmix boundary. Native PCM/Opus and the Browser microphone mix currently use two-channel output; adding a numeric channel count would silently overstate their capability. |
+
+The [W3C HDR interoperability discussion](https://github.com/w3c/webrtc-extensions/issues/253)
+documents Chrome decoding with VP9 profile 2 and color-space RTP metadata, while
+capture controls, interoperability tests and metadata standardization remain
+work areas. This supports a possible experiment, not universal Browser HDR.
+A local Windows Chrome 152 capability query advertised VP9 profile 2; it did
+not exercise HDR capture, encoding, display, Native reception or mixed viewers.
+
+[RFC 7587](https://www.rfc-editor.org/rfc/rfc7587.html#section-7)
+defines ordinary Opus RTP mono/stereo negotiation. Libwebrtc has a separate
+[multiopus implementation](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_coding/codecs/opus/audio_encoder_multi_channel_opus_impl.cc)
+with stream counts and channel mapping. Chromium's
+[SDP handling](https://chromium.googlesource.com/chromium/src/third_party/+/refs/heads/main/blink/renderer/modules/peerconnection/rtc_peer_connection.cc)
+notes that the Browser does not generate multiopus itself. The same local
+capability query offered ordinary Opus with two channels and no multiopus.
+These facts do not establish that manual negotiation is impossible; they rule
+out assuming that an existing Opus connection automatically becomes surround.
+
+Recommendation: retain stereo and HDR-to-SDR for the current candidate. Do not
+reserve speculative wire fields or promise full HDR/surround. Reopen an isolated
+prototype when a concrete source/receiver pair justifies it; first verify actual
+decoded colors or channel separation and a mixed-capability relay path. Reuse
+SDP/RTP format negotiation where it suffices rather than creating a second codec
+negotiation protocol. This assessment adds no feature or new release requirement.
diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md
index cee29a1ae..c0e04a7a7 100644
--- a/docs/research/nat-traversal.md
+++ b/docs/research/nat-traversal.md
@@ -1,6 +1,6 @@
# Browser And Native NAT Traversal
-Last reviewed: 2026-10-04
+Last reviewed: 2026-10-12
This document owns evidence for improving direct ICE without adding a
new relay or a custom transport. The current product contract remains standard
@@ -15,10 +15,9 @@ WebRTC ICE with STUN discovery and bounded SFU fallback.
- A STUN operator observes the client's source address, source port, request
time and protocol metadata. It never receives the DTLS-SRTP media path. An
external STUN endpoint is therefore a metadata dependency, not a media relay.
-- The current `STUN_URLS` wire already accepts a bounded list. Adding an
- auxiliary STUN destination needs no client compatibility surface, but it has
- no product value until a valid Browser observation or prediction consumer
- exists.
+- The current `STUN_URLS` wire already accepts a bounded list. Independent
+ ordinary endpoints can preserve discovery when one destination is unreachable;
+ additional observations alone do not establish better hole punching.
On 2026-08-31, Chrome 151 on Windows gathered a host plus IPv4 srflx candidate
through the self-hosted STUN endpoint and independently through Cloudflare's
@@ -27,6 +26,85 @@ configured, Chromium emitted one deduplicated srflx candidate. No raw address or
port was retained. This proves endpoint reachability and candidate
deduplication; it does not classify the NAT or prove another direct path.
+### Public-Link Discovery Redundancy
+
+A v1.9.2 report describes working chat but failed media, with only MiWiFi STUN
+responding on the reporter's network. It supplies neither paired ICE traces nor
+the share's prediction setting. The code confirms a narrower configuration gap:
+disabling prediction also removed both fallback discovery destinations, leaving
+Cloudflare alone. The candidate keeps all three existing destinations in ordinary
+ICE and reuses that set for prediction. It adds no provider, polling, URL ranking,
+fallback transport or new configuration surface; Local LAN remains unchanged.
+
+On 2026-10-11, local Chromium gathered a server-reflexive candidate from each
+destination independently and from the combined list. Some `701` error events
+coexisted with valid candidates, so an error event alone cannot establish complete
+discovery failure. The pinned Pion STUN client also received and decoded
+`XOR-MAPPED-ADDRESS` from each destination; the fallback is not Browser-only.
+Native regression checks confirm a healthy destination returns
+without waiting for silent UDP or unresolved DNS, and cancellation retires the
+survey. A Browser control with two failing DNS destinations still exposes the
+healthy MiWiFi result after 55 ms. A bound, deliberately silent UDP fixture
+beside a healthy fixture exposes the latter's synthetic srflx result after 4 ms,
+while gathering remains incomplete at 15 seconds. Closed-port controls did not
+produce the same result; a closed port is not a silent-drop fixture. The
+[upstream candidate path](https://webrtc.googlesource.com/src/+/refs/heads/main/p2p/base/stun_port.cc)
+emits successful mappings separately from gathering completion. These controls
+do not reproduce the reporter's network or establish its
+final connection cause. The [WebRTC event specification](https://www.w3.org/TR/webrtc/#rtcicecandidateerrorevent)
+owns error semantics; compare the attempt's gathered and selected candidates too.
+
+The follow-up also found a separate Native provenance gap on Sites with extra
+ordinary STUN destinations. All `nsN` results were eligible for prediction,
+while Browser observations were restricted to the configured three-URL survey.
+Native candidate markers now identify their requested destination, independent
+of response order. Both Native adapters filter prediction evidence against the
+same survey set and still forward every ordinary candidate. The Viewer retains
+the offer's actual destination list with its candidate batch, so a later config
+update cannot reinterpret an in-flight response. Controlled tests cover
+out-of-order results, an unrelated fourth destination, repeated mappings and
+stalled DNS.
+The Native collector must also retain each destination's observation until that
+eligibility filter. Deduplicating mapped endpoints first let a faster ordinary
+destination hide an equal survey response. The corrected collector preserves
+both identities; the existing predictor still deduplicates eligible mappings,
+so repeated equal mappings cannot establish a three-point pattern. ICE retains
+ownership of actual remote endpoint connectivity.
+This does not identify the public-invitation report's cause, whose configured
+set already contains exactly three destinations.
+
+### SFU Behind Server NAT
+
+An isolated Linux kernel-NAT control on 2026-10-09 places the current Server
+behind same-port UDP forwarding with `SFU_PUBLIC_IP`; direct access to its private
+address is blocked. Chromium 152 Host/Viewer pages use the real SFU route owners
+and authenticated signaling under `SFU_ONLY`. VP8/Opus reception, 720p-to-480p
+changes, fresh decoding after Viewer reauthentication and retirement all pass.
+Windows Native H264/Opus also passes through QEMU UDP forwarding and the Linux
+NAT, decoding 300 720p frames in Chrome 154, changing to 480p, recovering its
+subscription and reauthenticating after local media loss. The App captures a
+dedicated generated window; this does not reproduce a phone or carrier network.
+
+The published v1.9.2 Windows App/capture and Linux Server artifacts, verified
+against their release hashes and paired with that revision's Browser source,
+also pass this Native H264/Opus startup control: 300 decoded 720p frames, a 480p
+change, subscription recovery and retirement. This original-release control
+excludes reauthentication after lost local media, which has a separate confirmed
+repair below. The desktop control does not establish physical phone or carrier
+network coverage.
+
+Blocking only the Viewer process's UDP preserves connected Host ingress and
+LiveKit's `downtrack bound`, but the Viewer remains connecting without RTP or
+decoded frames. Its transport fails after about 15 seconds and the controller
+rejects that candidate without committing a publication. Thus binding plus
+healthy ingress cannot establish Viewer readiness or a controller defect.
+A separate control discards only the Viewer's RTP/RTCP datagrams while allowing
+STUN and DTLS. Both endpoints connect and the exact subscription binds, but no
+Viewer frames arrive; the existing 20-second operation deadline then expires.
+Connected endpoints, a bound track and an operation timeout do not identify a
+unique cause; diagnosis requires the affected Viewer's transport/decode evidence.
+Neither control changes a product deadline, retry or first-frame rule.
+
## Browser Prediction Preflight
A 2026-09-01 Chrome 151 preflight repeated the combined self-hosted plus
@@ -56,10 +134,11 @@ supports an opt-in mechanism, not a default or a participant-wide classifier.
## Current Decision
-An ordinary STUN endpoint remains the discovery service. A Site may enable
-same-host STUN-only listeners on 3479 and 3480. Public-link App mode supplies
-one ordinary public endpoint plus two public survey destinations; pure LAN mode
-supplies none. Available authorities expose the same per-share Host switch,
+Ordinary STUN remains the discovery service. A Site may enable same-host
+STUN-only listeners on 3479 and 3480. Public-link App mode supplies three ordinary
+public destinations and reuses them for prediction, so disabling prediction
+does not remove discovery redundancy; pure LAN mode supplies none.
+Available authorities expose the same per-share Host switch,
which defaults on and remains locked while sharing.
The accepted adapter is deliberately connection-local and additive. It waits for a
@@ -287,6 +366,26 @@ when hole punching fails; neither statistic transfers to our user population.
| Switch between direct and relay paths | Iroh manages QUIC paths; Piik hands off WebRTC edges through one room-route operation. These are different contracts. |
| Relay data over TLS/TCP when direct UDP fails | Iroh and Tailscale provide this. Piik currently has optional SFU/UDP, while App public invitations remain P2P-only. This is a coverage difference, not a missing prediction formula. |
+Reviewing [Tailscale v1.104.1's DISCO messages](https://github.com/tailscale/tailscale/blob/v1.104.1/disco/disco.go)
+and [endpoint handling](https://github.com/tailscale/tailscale/blob/v1.104.1/wgengine/magicsock/endpoint.go)
+gives a more specific ownership comparison:
+
+| DISCO behavior | Piik application |
+| --- | --- |
+| `CallMeMaybe` sends recently observed endpoints and requests return traffic after outbound probes. | Current-generation room signaling and Trickle ICE already exchange candidates. Optional Native preparation coordinates its bounded sends through that same signaling owner. A second discovery protocol adds no established benefit. |
+| Authenticated Ping/Pong ties responses to transactions and learns actual peer endpoints. | ICE already owns authenticated checks, triggered checks and peer-reflexive discovery. Native preparation additionally requires the current operation's key and actual response before socket adoption. Do not maintain a parallel peer-address database. |
+| Endpoint trust expires; fresh observations replace stale advertised addresses. | Existing connection-owned discovery, gateway refresh and ICE recovery own freshness. Preserve their generations and retirement instead of adding another keepalive/retry timer. |
+| Peers can coordinate even when their mappings behave differently. | The candidate incorrectly declined preparation whenever either side observed a stable mapping. Retaining that side's actual observation socket lets it participate alongside a sequentially mapped peer. This is the concrete gap adopted below. |
+
+These are mechanism correspondences, not proof that the two stacks have equal
+reachability. [ICE's peer-reflexive processing](https://www.rfc-editor.org/rfc/rfc8445.html#section-7.2.5.3)
+can discover an endpoint different from the advertised candidates; it must
+remain the owner of that discovery and nomination. Extending the preflight to
+accept arbitrary authenticated reply ports would also require mutual winner
+selection, duplicating ICE. The current extension keeps one expected peer tuple
+and leaves ordinary ICE usable when the finite preparation fails. It adds no
+DERP service, media wrapper or new dependency.
+
[Tailscale magicsock](https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go)
is implemented in Go and is integrated with WireGuard, peer discovery, network
maps and DERP. Its language is not an obstacle, but it is not an interchangeable
@@ -304,62 +403,449 @@ hold endpoint pairs, NAT/filtering, IPv6, network transitions and relay policy
constant, measuring direct success, relay use and first media separately.
No new transport or universal success-rate claim is accepted by this review.
-The isolated `spike/magicsock-feasibility` Go experiment on 2026-10-04 carried
-Pion ICE/DTLS and matching synthetic audio/video RTP payloads over magicsock,
-then delivered new payloads after both UDP endpoints changed without new SDP.
-Loopback and separate address-and-port-filtering NATs passed, including five
-race-detector repetitions. The fixture supplied authenticated peer identities
-and endpoint updates; it did not test automatic OS network-change detection,
-decoded playback or real routers. A separate seven-pair NAT matrix found the
-same direct reachability as Pion within four-second windows, not a field success
-rate. Go integration is feasible; no Rust requirement was found. Product use
-still needs room-authorized key/endpoint exchange, MTU/resource limits and mixed
-Browser/Native behavior. The nested probe module and full evidence stay on that
-isolated branch; production dependencies and routing remain unchanged.
+The [retired Go experiment](#integration-cost-and-recovery-controls) establishes
+reuse feasibility and its cost, not a Browser-compatible reachability gain.
+
+### Coordinated Native Punching Assessment
+
+The October 12, 2026 scope includes optional Native transport at both ends when
+it provides a measured gain. Background App availability makes this usable;
+ordinary Browser peers must retain their existing connection path.
+
+The ordinary adapter already predicts on both ends of an eligible edge, but each
+end independently supplies at most eight adjacent candidates from one media
+socket. It does not coordinate probing order or open a pool of local sockets.
+Completing the ordinary-ICE and Magicsock controls therefore does not establish
+the limit of coordinated Native hole punching.
+
+Mature implementations provide a concrete comparison:
+
+- [frp's strategy table](https://github.com/fatedier/frp/blob/2542d11a6201c0f2bbe3b1d93031fc1e83201056/pkg/nathole/analysis.go)
+ separates predictable-port, role/timing and probabilistic strategies. One
+ strategy pairs 256 local listeners with 1,000 remote port guesses. Those are
+ upstream operating budgets, not accepted Piik defaults or a success guarantee.
+- [EasyTier's Native implementation](https://github.com/EasyTier/EasyTier/blob/1649ec00135e12103e05bf543e0ed313d7e190f8/easytier-core/src/connectivity/hole_punch/udp/client.rs)
+ uses socket arrays and coordinated peer requests, with separate predictable
+ and random-port paths. Its implementation is evidence that these are real
+ mechanisms, not evidence that replacing Piik's media stack improves coverage.
+
+An isolated Go prototype now compares ordinary ICE, the current eight-candidate
+prediction policy, an ordered first tuple, a coordinated eight-socket pool and
+ordinary ICE over eight preallocated sockets. The pool authenticates generation-
+specific STUN transactions, closes its losing sockets and passes its actual
+winning socket to stock Pion WebRTC. Success requires bidirectional SRTP payload
+delivery and reverse RTCP feedback. All strategies share a four-second overall
+deadline; no relay, OS socket or external network is used in these controls.
+The retained implementation is commit `807c7390` on
+`spike/magicsock-assessment`, under `scripts/magicsock-probe/native_punch.go`
+and its tests; it is not part of the product module.
+
+In Pion's sequential endpoint-dependent NAT fixture, coordinated first sends
+connect the otherwise timing-sensitive bilateral tuple. Three unrelated mappings
+after observation defeat that simple first-tuple guess; the pool still connects.
+The current candidate policy can also occasionally win through favorable check
+scheduling. This is evidence for more deliberate coordination, not a newly proven
+unreachable NAT class or a public success-rate claim. Merely preallocating sockets
+does not establish the same benefit. Six unrelated mappings and blocked peer UDP
+defeat this pool's bounded prediction.
+
+The pool uses eight sockets, at most 64 requests plus 64 authenticated replies,
+one predicted remote endpoint, approximately 200 ms of overlapping sends and a
+500 ms cleanup deadline. Cancellation, allocation failure, wrong-generation
+credentials, first-packet loss and winner adoption have explicit controls.
+100 ms of peer-start skew succeeds in the fixture; 350 ms exceeds its overlap
+and fails cleanly. These are experimental budgets, not product defaults.
+
+The isolated policy comparator follows TS emission order, but surveys run before
+ICE; ordinary production gathering trickles while STUN work is still running.
+Its synthetic RTP does not establish decoded pictures or complete control and
+signaling integration.
+
+The 2.0 candidate now adopts the winning socket before constructing Pion, only
+on an existing server-issued retry. Pion's WebRTC API does not expose ICE's
+`AddVirtualCandidate`; this avoids a private late-socket API, a second route
+owner and a delay to the first ordinary attempt. Browser peers decline this
+Native preparation and continue ordinary ICE. The authorized phases and resource
+bounds are owned by [ADR-0009](../adr/0009-optional-nat-prediction.md).
+
+The production Engine's virtual-network regression carries VP8 SRTP into its
+actual Receiver and returns SRTCP key-frame feedback after socket transfer,
+including 250 ms of peer-start skew. It retains the proved remote tuple rather
+than replacing it with a later, destination-dependent STUN mapping. Other
+controls cover early/late cancellation, source/Engine retirement, premature
+commit, resource limits, stale signaling and Browser decline. These establish
+bounded composition, not a physical difficult-network success-rate improvement.
+The candidate's send window is 800 ms within a 1.1-second hard bound; the earlier
+isolated experiment's shorter window is not its product timing.
+
+Stable observations now use the exact survey socket in that same preparation;
+reopening a socket would invalidate the observed address. Mixed stable/sequential
+fixtures previously declined at observation and now carry production SRTP and
+reverse SRTCP in both directions, including 250 ms of start skew. Stable/stable
+and sequential/sequential controls also pass. This removes an eligibility gap;
+it does not prove those pairs could never connect through ordinary ICE. Three
+equal observations, especially against different ports on one STUN IP, do not
+establish endpoint-independent mapping for every destination. Wrong-generation
+responses remain rejected, and no-response paths remain bounded. Raw survey I/O
+has the survey deadline and cancellation interruption; Pion's reader and
+retransmitter and the cancellation callback finish before a socket transfers.
+Blocked initial/retransmission writes and unused stable-socket retirement have
+regression coverage. The shared wire decoder requires an explicit zero step;
+missing/null fields cannot silently select this behavior.
+
+The Windows Browser-to-App fanout gate also exercises the actual room retry,
+both Native control preparations and ordinary ICE continuation when the mapping
+is ineligible. Two Viewers decode about 30 fps; live quality, pause/resume, source
+audio changes and App-exit fallback remain usable. Run the existing
+`gate:app-browser-fanout` entry with `PIIK_CLIENT_BROWSER_FANOUT_GATE=true`,
+`PIIK_CLIENT_PUNCH_GATE=true` and `CHROME_PATH` set. This arm uses loopback STUN
+listeners on UDP 3478/3479/3480 and injects one candidate rejection; it does not
+contact public STUN services.
+
+The [process laboratory](./network-lab.md#running-the-process-layer) now verifies
+successful preparation and socket adoption through sequential Linux kernel NAT,
+including decoded picture/audio continuity and reverse feedback in actual App
+processes. Its Browser and mixed controls do not establish a gain in that profile.
+Physical difficult networks and cross-device behavior remain field-beta checks.
+Random-port overlap assumptions do not establish a solution for two independently
+random endpoint-dependent NATs or blocked UDP.
+
+### Browser-Compatible Follow-Up
+
+The product target remains App-to-Browser and Browser-to-Browser sharing without
+requiring every Viewer to install software. Existing IPv6, bounded gateway
+mapping and standard ICE can benefit those edges; a new transport must be judged
+against that same endpoint pair and relay policy.
+
+- The [2026 DCUtR paper](https://arxiv.org/abs/2604.12484) covers 4.4 million
+ attempts across more than 85,000 networks, collected in December 2022 through
+ January 2023. Its roughly 70% success is conditional
+ on relay reservation and address discovery; 97.6% of successful punches occur
+ on the first attempt. These are libp2p results, not a Piik baseline. They justify
+ separating preparation failures from hole-punch failures and measuring the
+ marginal benefit of retries, not copying a retry count or replacing ICE. Its
+ proposed role reversal and probabilistic port probing are future work, not
+ measured improvements in that campaign. Standard full ICE already checks in
+ both directions; a QUIC initiator-role proposal is not evidence for changing
+ Piik's Host authority or adding another retry owner.
+- [QUIC hole punching and migration](https://arxiv.org/abs/2408.01791) reports
+ shorter connection/restoration latency than its TCP/re-punching controls.
+ It does not demonstrate a drop-in improvement for Browser WebRTC. Current
+ [WebTransport](https://www.w3.org/TR/webtransport/) targets Browser-to-server
+ transport; it is not an arbitrary UDP socket for Magicsock.
+- [ICE renomination](https://datatracker.ietf.org/doc/draft-thatcher-tsvwg-renomination/)
+ proposes changing the selected candidate pair without a full restart. It
+ remains an Internet-Draft. The tested Pion WebRTC v4.2.19 already exposed
+ `SetICERenomination`; Piik does not enable it. That ICE v4.4.0 implementation
+ references an earlier draft and automatically probes and reselects candidate
+ pairs. Verify wire-version agreement, Browser interoperability, probe cost and
+ recovery benefit before enabling it. This targets path changes after connection,
+ not a demonstrated increase in initial NAT reachability. Keep selection in the
+ framework; do not add a Piik path-selection loop.
+- [Direct Sockets](https://developer.chrome.com/docs/iwa/direct-sockets) is
+ restricted to explicitly installed Isolated Web Apps. It does not remove the
+ installation requirement for an ordinary invitation page.
+- [SPED](https://datatracker.ietf.org/doc/draft-hancke-webrtc-sped/) carries DTLS
+ handshake data in STUN so ICE and encryption setup can overlap. Its target is
+ setup latency and handshake loss recovery, not newly reachable NAT classes.
+ The March 2026 individual draft is expired as of this review; implementation
+ interest does not establish interoperable product support. Evaluate only
+ framework-owned negotiation and stock Browser builds, retaining authentication
+ and ordinary fallback. A Browser field-trial flag is not an adoption path.
+- The July 2026 [WARP draft](https://datatracker.ietf.org/doc/html/draft-uberti-tsvwg-warp-00)
+ combines SPED, SCTP setup negotiation (SNAP) and DTLS 1.3. Its combined model
+ reduces media-and-data setup from six round trips to two; media alone starts
+ after four round trips in its baseline. These are handshake savings, not
+ evidence that previously unreachable NAT pairs connect. Pion's
+ [ICE v5 release candidate](https://github.com/pion/ice/releases/tag/v5.0.0-rc.6)
+ already contains SPED/DTLS 1.3 work, so an expired individual draft does not
+ imply abandoned implementation. Follow stable framework support and negotiated
+ stock-Browser interoperability before a latency comparison; this assessment
+ does not accept a prerelease transport upgrade or changes to ICE consent.
+
+#### Stable Pion Interoperability
+
+The candidate uses Pion WebRTC v4.2.23 / ICE v4.4.7. The
+[upstream changes](https://github.com/pion/ice/compare/v4.4.0...v4.4.7) include
+STUN retransmission, gathering-generation ownership, Chromium mDNS compatibility
+and pre-created mux lifetime corrections. This updates the shared Native/SFU
+composition; ordinary Browser-to-Browser ICE remains Browser-owned.
+
+A controlled Chrome 152 check reproduced the
+[mDNS query incompatibility](https://github.com/pion/mdns/commit/5b0a717e4ff6e81300d54ce2fef53e635b95fe3c):
+after the Browser's initial announcement, a fresh v2.1.0 resolver could not resolve
+its generated `.local` candidate before the fixture's four-second deadline.
+v2.2.2 resolved the same name in about 12 ms; another fresh old resolver still
+timed out. The upstream repair sends separate A/AAAA questions without the
+unicast-response bit that Chromium rejects. This fixes an address-resolution
+boundary useful to App-to-Browser edges; it is not a field success-rate result
+or proof of the cause of an unmatched report. Peer-reflexive discovery can also
+let a connection succeed despite failed mDNS resolution.
+
+The package-major adaptations retain Pion's STUN transaction owner and SRTP
+buffer bounds/attributes. LiveKit forwarding and the existing ICE recovery
+owners remain unchanged. Chrome 152 and Firefox 151.0.3 recovered synthetic VP8,
+reverse RTCP and echoed data through ordinary ICE restart after the selected
+UDP path was cut. Shared forwarding, Native edges, SFU and STUN tests passed,
+including Linux race checks and a disposable-namespace interface-replacement
+control. No new automatic renomination, probing loop or retry budget is enabled.
+
+Keep Piik's fresh-interface mux and connection-owned STUN/mapping repairs: the
+newer framework still snapshots its default interface view and caches successful
+universal-mux observations. An upstream release-note match is not sufficient to
+remove these protections. Physical network/device coverage remains in
+[verification status](../verification-status.md#remaining-device-and-network-acceptance).
+
+#### Pion Renomination Preflight
+
+The current decision is not to enable automatic renomination. An isolated
+two-UDP-path control with pinned WebRTC v4.2.19/ICE v4.4.0 cut the selected socket
+after stable VP8 playback. Chromium 152 recovered video and reverse feedback
+without new SDP after about 5.6 seconds. Firefox 151.0.3 repeatedly resumed video
+after about 5.7 seconds but retained the failed outbound candidate: RTCP reception
+and echoed data stopped for the full 20-second observation while new video kept
+decoding. An explicit ICE restart on that same connection selected the surviving
+path and restored both reverse flows. The stock-ICE controls also recovered
+through restart.
+
+Both Browsers answered with only `trickle`; the enabled Pion call nevertheless
+sent the older nomination attribute. Thus receiving new frames alone cannot
+establish compatible path migration. In these fixtures, steady Binding requests
+also rose from 3 to 27-75 per five seconds, depending on interface enumeration.
+These are local transport controls, not physical-network success measurements or
+a defect in Piik's currently disabled feature. Reopen only for negotiated Browser
+support and bounded probe/recovery behavior; do not add UA exceptions, a second
+recovery loop or a Browser fork to enable this option.
### Integration Cost And Recovery Controls
-The 2026-10-06 assessment retains the probes on `spike/magicsock-assessment`
-at `fea0587f`, based on current main. These nested Go modules are outside Piik's
-build and dependency graph. Repeated local Tailcat bootstrap, peer denial,
-revocation and synthetic SRTP media checks passed; the raw magicsock direct-path
-matrix again found the same reachable classes as fresh Pion ICE. Retired shared
-mapping-cache reproductions remain causal controls, not the current baseline.
-
-[Tailcat](https://github.com/tailscale/tailcat/blob/b4dc28e8aa8936f0a90a41ad8293a64e3d6b645f/README.md)
-provides a supported Go reuse lead: magicsock plus WireGuard, with DERP bootstrap
-and relay fallback. Its Browser path at that pin is relay-only. In the isolated
-Linux kernel NAT comparison on 2026-10-05, both stacks connected directly through
-ordinary/ordinary and open/hard gateways in either direction. Neither delivered
-fresh direct payloads through ordinary/hard, hard/ordinary, hard/hard or blocked
-UDP pairs within the observation window. Tailcat delivered those cases while
-the fixture relay remained available. This demonstrates relay coverage, not
-an additional P2P reachability class or a field success rate.
-
-Replacing one gateway mapping without notifying endpoints separated recovery
-from initial reachability. Neither established direct path delivered within
-20 seconds with the relay closed. Fresh Pion discovery/exchange recovered in
-about 0.6 seconds after it was started. Tailcat recovered delivery in about
-6.5 seconds through its retained relay; closing it immediately lost delivery
-again. Keeping traffic and discovery alive for 45 seconds allowed subsequent
-direct delivery after relay closure. Both gateway directions were checked.
-These timings have different detection/setup boundaries and are not a Piik
-startup or playback benchmark.
-
-The raw Bind adapter also has a composition cost: it consumes unwrapped STUN
-payloads, and its discovery retry can outlive Pion's initial check budget.
-Dropping the first discovery packet in each direction reproduced that failure;
-increasing only the fixture's ICE budget recovered media. The ordinary Pion
-loss control recovered without that override. Do not adopt packet prefixes,
-diagnostic Ping polling or wider product deadlines as integration shortcuts.
-
-Current recommendation: keep the transport isolated. Go reuse is feasible, but
-new key authorization/revocation, underlay readiness, packet-size limits and
-relay operation have no demonstrated direct-coverage benefit sufficient for
-product integration. A future trial needs matched networks with a repeatable
-benefit, separate direct/relayed/first-media results, and current room/route
-ownership. IPv6, gateway mapping, Piik prediction, physical routers, decoded
-playback and mixed Browser/Native fanout remain outside this comparison.
+**Current candidate excludes Magicsock; optional-helper adoption needs a matched gain.**
+This composition requires Native App transport at both ends of the enhanced
+edge. Those participants can still use the existing Browser UI and local media
+bridge; Native transport does not require a separate desktop interface. A
+Browser-only endpoint continues to use ordinary WebRTC.
+
+The background-App proposal is an optional transport helper for the same Web
+workflow. The existing standby and saved Site approval can reduce repeated
+setup, but do not remove first-use authorization, Browser local-network
+permissions, or the integration costs below. Reassess adoption against that
+optional-helper scope, preserving ordinary ICE when the helper is absent or
+unavailable. The controls established no additional reachable NAT class;
+background availability alone is not reachability evidence or acceptance of
+DERP media fallback. Add it to the release candidate only after a matched benefit
+and the integration boundaries below are established.
+
+The isolated experiment remains at `cc6f9d0d` on
+`spike/magicsock-assessment`, outside Piik's production dependency graph.
+The `scripts/magicsock-probe/README.md` and `scripts/tailcat-probe/README.md`
+files at that commit own rerun commands, fixture boundaries and opt-in
+dependency patches. The complete earlier experimental
+observations remain in `git show 82e3f253:docs/research/nat-traversal.md`.
+Its internal package is retired, not a current 2.0 test deliverable.
+
+#### Current Stable Direct-Transport Control
+
+On October 12, 2026, the isolated raw-Bind module was refreshed to
+[Tailscale v1.104.1](https://github.com/tailscale/tailscale/releases/tag/v1.104.1)
+and the candidate's Pion WebRTC v4.2.23 / ICE v4.4.7. The seven virtual-router
+pairs, with relay disabled, produced the same results with four- and
+twenty-second direct observation windows. In both stacks, cone/cone,
+restricted/restricted and cone/hard in either direction connected;
+restricted/hard in either direction and hard/hard did not. These are
+stock-stack mechanism controls, not Piik's complete gateway
+mapping/prediction composition or physical success-rate measurements.
+
+Warmed Magicsock carried ICE/DTLS and audio/video SRTP, including two observed
+address changes without new SDP. But losing the first discovery packet in each
+direction during cold startup exhausted inner ICE after about 1.7 seconds;
+three repeats confirmed the failure. Stock Pion under the equivalent initial
+discovery loss connected in about 0.6 seconds. Extending only the fixture's ICE
+check budget let the nested composition connect after about 5.3 seconds. This
+identifies a composition cost, not an accepted timeout increase or universal
+claim that Magicsock is slower.
+
+Raw Bind also consumed bare inner STUN, requiring a test-only packet prefix.
+After peer removal it could still deliver UDP without peer identity; raw
+reception is not authentication. A product adapter would need supported
+framing and existing room/cryptographic authorization, not just another socket.
+The refreshed full probe suite and longer-window matrix passed, including
+controls that expect connection failure. They do not establish a new reason
+to include Magicsock in this candidate.
+
+#### Reachability And Cost
+
+The October 5-10, 2026 controls used
+[Tailcat](https://github.com/tailscale/tailcat/blob/b4dc28e8aa8936f0a90a41ad8293a64e3d6b645f/README.md):
+Magicsock/WireGuard with DERP bootstrap and relay fallback. Its Browser path at
+that pin is relay-only. Tailscale's
+[Browser P2P proposal](https://github.com/tailscale/tailscale/issues/21027)
+uses WebRTC; it is not an implemented Browser Magicsock hole-puncher.
+
+| Controlled question | Result and boundary |
+| --- | --- |
+| Does it reach more NAT classes than fresh ordinary ICE? | In the initial kernel-NAT matrix without a usable gateway mapping, both connected directly through ordinary/ordinary and open/hard gateways in either direction. Neither established direct media through the other tested hard/blocked combinations within the observation windows. DERP delivered otherwise unreachable cases, establishing relay coverage rather than additional P2P coverage. |
+| Does network-change handling help? | Some local-address changes recovered on the same session without new SDP. Gateway-only rebinding and longer interruptions had separate discovery/recovery failures. These results do not establish physical roaming, a universal recovery improvement or increased initial reachability. |
+| Does it coexist with ordinary ICE? | An isolated optional multi-mux composition retained ordinary media with bootstrap unavailable and used a delayed tunnel candidate when needed. It required dependency patches and readiness/cancellation integration; it is not a supported plug-in. |
+| What is the bounded cost? | Matched stripped Windows App builds grew from 44.1 to 59.8 MiB. A local four-Viewer full-App comparison peaked at 81.8/93.2 MiB RSS for ordinary/optional modes; final goroutines returned to the same baseline. Codec and Browser processes were separate. These are not sustained or per-room budgets. |
+
+Closing DERP removes endpoint exchange as well as relay media. Later controls
+therefore retained discovery and counted encrypted relay traffic separately;
+a selected direct address alone was insufficient. Tests required fresh media
+and either no relayed data or continued delivery after relay closure. The
+Native mapping, prediction and dual-stack controls are retained
+[below](#gateway-and-address-family-controls).
+
+#### Integration Boundaries
+
+The experimental composition exposed costs that a future adoption must address
+without another retry owner, timeout extension or silent relay policy change:
+
+- **Relay policy:** the reviewed upstream API has no direct-only media policy
+ retaining discovery. An isolated overlay filtered encrypted data in both
+ directions while allowing discovery/handshakes. This is not an accepted
+ product mechanism; disabled UDP must not silently become DERP media.
+- **Startup and recovery:** outer discovery can outlast the inner ICE check
+ budget. A diagnostic `DiscoPing` reply did not prove bidirectional media
+ readiness. Experiments with more checks, extra prefaces and readiness probes
+ did not establish a universal fix. Initial advertisement also needed an
+ isolated dependency correction to send direct probes before relying on the
+ peer's stateful firewall. Ordinary ICE must remain independently usable.
+- **Datagram bounds:** Tailcat's reviewed payload ceiling is 1232 bytes. Actual
+ encrypted TWCC reports reached 1244 bytes, with compound batches larger still;
+ ordinary Pion delivered them while the tunnel did not. A delayed packet could
+ grow a report despite a shorter feedback interval. The isolated recorder/writer
+ split preserves statuses, but is not a supported upstream option. The
+ [feedback-MTU issue](https://github.com/pion/interceptor/issues/416) and recorded
+ dependency patches own this finding; reducing RTP size alone is insufficient.
+- **Authorization:** the pinned Tailcat/WireGuard composition could retain a
+ revoked inbound peer or install it from a late authorization result. Scoped
+ experimental corrections passed revocation, unrelated-peer, sibling-media
+ and race controls. Piik's production transport does not use these dependencies;
+ the result is an adoption requirement, not a published Piik defect.
+- **Flow ownership:** unrelated listener changes could discard an existing
+ inbound filter entry. Duplicate client identities could collide at DERP;
+ close-before-initialization was not terminal. Isolated repairs preserved
+ filter state, used fresh parent-bound clients and joined canceled creation.
+ New media flows must retire through their existing connection owner.
+- **Optional candidate lifecycle:** stock multi-mux construction and supported
+ dialing do not independently provide the required delayed socket admission.
+ A private reservation API plus the existing candidate collector kept ordinary
+ ICE independent, advertised the tunnel only when ready, fenced cancellation
+ and delayed end-of-candidates. Its multi-mux also needed a correction for
+ newly enumerated child addresses. These remain experimental dependency changes.
+
+#### Scope Of The Retained Evidence
+
+The isolated overlays exercised real Pion/LiveKit forwarding, H264/VP8 and Opus,
+Browser decoding, Native control and room authorization. Bounded rendered checks
+covered reconnect, invitation revocation, overlapping parent replacement,
+healthy siblings, denial at the room capacity limit and final resource cleanup.
+Generated changing pixels distinguished fresh output from retained frames.
+The final full-App controls could select the tunnel with ordinary remote
+candidates withheld, while ordinary Web edges retained ICE.
+
+Those checks established local composition and ownership behavior. They did
+not establish physical capture, difficult-network benefit, broad mobile support
+or sustained field reliability. The experiment's detailed intermediate controls,
+failed counterfactuals and package evidence remain in Git rather than this
+candidate's work ledger. Independent ordinary-media findings are retained here
+and in their existing owners.
+
+## Ordinary ICE And Media Recovery
+
+These findings apply without Magicsock. They must remain part of candidate
+acceptance independently of the retired experiment.
+
+### Interface Refresh And Local Browser Bridge
+
+The local restart control reproduced an address-cache defect: Pion ICE v4.4.0's
+[wildcard UDP mux](https://github.com/pion/ice/blob/v4.4.0/udp_mux.go) retained its
+own `stdnet.Net` interface snapshot across gatherings. After an interface address
+changed, both ICE restart and a new SFU connection sharing that mux could
+advertise the removed address. The shared mux now takes a private current
+snapshot when gathering. Socket identity and Pion's candidate filters remain;
+no monitor or timer is added. The regression covers both restart and a new
+connection, and restoring the former constructor fails on the stale candidate.
+The [stable Pion update](#stable-pion-interoperability) still needs this adapter.
+
+Forcing the same-machine Browser bridge to loopback is not a portable solution.
+A Windows control decoded through `127.0.0.1` in Chrome 154 but failed in
+Firefox 155; Firefox decoded using the current wildcard bind and a non-loopback
+pair. Mozilla keeps
+[ICE loopback disabled by default](https://searchfox.org/firefox-main/source/modules/libpref/init/all.js).
+These observations establish a compatibility limit, not every Browser permission
+or public-Site behavior. Keep the existing address policy.
+
+Complete App controls on October 10, 2026 reproduced the consequence in a
+disposable Linux namespace. Chromium 152 retained its loopback bridge after a
+LAN address change. Firefox 151 selected the removed LAN address: its Host
+ended an otherwise healthy share, while its Viewer eventually used Browser
+fallback. Fresh rendered frames, rather than accumulated frame counts, were
+used for acceptance. The source was generated VP8 through the real capture
+process boundary, not physical capture.
+
+The corrected existing bridge owner performs standard ICE restart after a
+three-second disconnect grace or immediately on failure. Initial and restart
+negotiations use complete SDP and fence answers by request ID. The eight-second
+negotiation bound includes up to five seconds of Native gathering; retirement
+cancels it. Existing Host/Viewer terminal owners handle failure, and paused
+sources need not produce frames to establish the transport connection.
+There is no second local candidate queue or recovery controller.
+
+Matched controls retained the same Firefox Host and Viewer tracks through the
+address change. Host sharing and downstream decoding continued; the Native
+Viewer recovered without a route replacement or Browser fallback. Chromium
+retained its working loopback leg. Replacing only the Native PeerConnection,
+while keeping the Browser connection, instead reported connected without fresh
+pictures in a counterfactual. The tests therefore support standard ICE restart,
+not unilateral reconstruction or a claim of physical roaming coverage.
+
+### Recovery Ownership And Forwarding
+
+A real Viewer-owner fixture retained sockets and external conditions through
+interruption and replacement. It exposed recovery counts being renewed with a
+new Native/Browser backend, permitting an extra request. The corrected budget
+belongs to the parent opportunity, as specified by the existing
+[recovery owner](../standards/routing-transport.md#reconciliation).
+Trickle-exchange controls recovered ordinary Pion after two-, nine- and
+eleven-second interruptions in three runs each. A full-gathering fixture failed
+while the network was unavailable; that negative control was a fixture limit,
+not evidence for extending product deadlines.
+
+A separate live Windows VP8/Native forwarding control exposed stale decoded
+pictures after roughly one second of UDP loss: hundreds of already-encoded
+packets remained in the former throttled queue. Forwarder frame counts preceded
+actual paced writes and could not prove delivery. With the upstream default send
+queue and the local playback leg isolated, all six ordinary/experimental
+unchanged, address-change and short-loss controls retained fresh decoded
+content after relay closure. The
+[send-queue assessment](./node-local-media-design.md#send-queue-recovery)
+owns the constrained-bandwidth comparison and adopted correction. This is not
+proof that every brief blur or unmatched interruption shares that cause.
+
+The same full-room work found a denied Viewer retaining its App control slot
+across repeated invitation generations. The
+[control-retirement assessment](./native-client-lifecycle.md#viewer-control-retirement)
+owns that independent repair and its rendered acceptance boundary.
+
+### Gateway And Address-Family Controls
+
+The actual Native `mediaedge.Engine` Host/Viewer also ran through Linux kernel
+gateways with local playback and the unchanged TypeScript predictor. Three race
+repetitions required distinct generated VP8 RTP, excluding padding and duplicate
+packets. Without a gateway service, best-effort mapping did not block ordinary
+media behind discovery. Either usable IP family carried media when the other
+was blocked. Sequential-port NAT failed without prediction and used a predicted
+candidate when enabled, with the changing-port gateway on either endpoint;
+random-port NAT remained unreachable in the bounded controls. A real MiniUPnPd
+gateway made a hard/hard pair reachable with mapping enabled.
+
+These are encoded-media controls, not physical capture, Browser decode, complete
+room admission or a new field-success measurement. The matched Tailcat controls
+needed its optional portmapper module and a fair multi-region STUN projection;
+they still established no additional direct path. Existing mapping, prediction
+and IPv6 should be assessed against the same endpoints and relay policy before
+another mechanism is proposed.
## Native Mapping Cache And Retry Lifetimes
diff --git a/docs/research/native-client-lifecycle.md b/docs/research/native-client-lifecycle.md
index 08303c358..31da5b30b 100644
--- a/docs/research/native-client-lifecycle.md
+++ b/docs/research/native-client-lifecycle.md
@@ -37,6 +37,19 @@ waiting; later grants can be used by later connections without replacing healthy
Browser media. These checks do not identify the original missing-window
reporter's cause or establish other browsers' permission behavior.
+### Viewer Control Retirement
+
+A denied Viewer page can stay mounted or enter the Browser's
+[back/forward cache](https://web.dev/articles/bfcache). Chromium 152 retained its
+App control socket after invitation revocation; repeated entry eventually
+exhausted the App's two control slots and used Browser reception instead.
+The Viewer room owner now releases control on access loss and rejects discovery
+that completes after retirement. A rendered optional-transport fixture retained
+the denied pages across four invitation generations: each new generation used
+Native reception with changing decoded pixels, old grants remained rejected,
+and revocation cleared media resources. This is local synthetic-media evidence;
+it does not establish physical network or all Browser navigation behavior.
+
## Scope Decisions
- Keep one strict loopback wire and one-share/one-session fence.
@@ -309,6 +322,17 @@ starts at build 20348, beyond consumer Windows 10 build 19045. The current
capture helper therefore retains the system border there. Browser capture
indicators remain Browser-owned.
+The helper submits `IsBorderRequired(false)` after the access request completes,
+including a denied result, as in the
+[Windows capture sample](https://github.com/robmikh/Win32CaptureSample/blob/master/Win32CaptureSample/App.cpp).
+Windows enforces permission and can honor the preference if consent changes
+during capture. The earlier `Allowed` guard incorrectly made the first
+permission result decide the session's lasting preference. The 2026-10-07 review
+removed that guard; native checks and local capture/stop cycles with the border
+preference off/on/off passed. A visible denied-to-allowed transition and the new
+reporter's setup remain unverified; this correction does not establish the
+reported cause or guarantee border visibility.
+
On Windows 11 build 26200, a 2026-09-21 controlled native-window check confirmed
borderless permission was allowed and the default capture had no border. A
second session explicitly requiring a border made it visible; gracefully ending
@@ -332,6 +356,20 @@ This establishes a retirement defect and its local repair, not the cause of
every reported Windows 11 border: denied consent and other active captures
remain distinct Windows-owned limits.
+The source-preview call chain has a separate, finite lifetime. Closing the
+picker invalidates queued thumbnails and their results; it does not kill the
+in-flight helper. That helper closes its WGC session before writing the bitmap.
+Control-session loss or the three-second preview deadline can still terminate
+it through `CommandContext`. The 2026-10-08 review did not reproduce a retained
+border from this path. Its desktop-pixel control could not see the controlled
+source, so capture/permission logs alone were not accepted as visual evidence.
+
+The upstream [display-resolution change report](https://github.com/robmikh/Win32CaptureSample/issues/59)
+is still open as of 2026-10-07: it reports borderless display capture losing that
+preference after a mode change. It gives neither a current Windows build nor a
+verified repair. Include display-mode changes in physical reproduction; this
+report alone does not justify toggling the property or restarting capture.
+
An unofficial WGC-preserving option exists:
[Windhawk's DWM Custom Projection Border](https://github.com/ramensoftware/windhawk-mods/blob/main/mods/dwm-custom-projection-border.wh.cpp)
documents disabling the border on Windows 10 21H2. Source inspection confirms
diff --git a/docs/research/native-client-media.md b/docs/research/native-client-media.md
index 8da28b7e4..82c694bc2 100644
--- a/docs/research/native-client-media.md
+++ b/docs/research/native-client-media.md
@@ -1,10 +1,10 @@
# Native App Media Evidence
-- Reviewed: 2026-10-06
+- Reviewed: 2026-10-11
- Scope: platform capture, shared encoded sources, Pion transport and Browser
decode; current behavior belongs to [media quality](../standards/media-quality.md)
-- Status: Windows physical native Host and Viewer gates passed; macOS and Linux adapters
- compile and package but still require physical media gates
+- Status: Windows physical native Host and Viewer gates passed; both macOS
+ architectures compile and pass packaged startup; macOS/Linux physical media gates remain open
## Windows Audio Exclusion
@@ -32,10 +32,10 @@ application's process tree or simultaneous playback across several output device
device acceptance boundaries. [ADR-0008](../adr/0008-window-scoped-audio-capture.md)
owns the platform choice.
-The capability check now attempts exclusion activation on Windows 10 2004+
-instead of rejecting all builds below 20348. Microsoft documents 20348 as the
-minimum, while OBS documents application loopback on Windows 10 2004+; the
-activation result, not an OS label, controls availability. Physical Windows 10
+The capability check attempts inclusion and exclusion activation independently,
+without an OS-version gate. Microsoft documents 20348 as the minimum, while OBS
+documents application loopback on Windows 10 2004+; the activation result, not
+an OS label, controls availability. Physical Windows 10
exclusion remains unverified. A session regression check changes exclusion and
source-audio off/on while video preparation is deliberately unavailable: the
capture stream, encoded source, audio output and connected edge remain the same.
@@ -118,8 +118,8 @@ congestion behavior or a paired no-Site Browser media session.
The audio adapter uses `github.com/thesyncim/gopus` behind the private
`nativeaudio` boundary. It accepts fixed 48 kHz stereo PCM16/20 ms frames,
encodes into a caller-owned Opus buffer, and can be replaced without changing
-the media edge or control wire. The dependency is BSD-3-Clause licensed and
-requires Go 1.25; App CI uses Go 1.26.6.
+the media edge or control wire. The dependency is BSD-3-Clause licensed;
+App CI follows the toolchain declared in [go.mod](../../go.mod).
## Current Boundary
@@ -357,6 +357,49 @@ that result nor the allocation timings establish the reported system-wide lag's
cause. Retain the existing implementation; reopen optimization from a matched
bottleneck.
+### Virtual-Display Acceptance Limit
+
+On 2026-10-07, the local Native H.264 SFU gate initially passed its two-room
+check, then missed its unchanged 300-frame/30-second requirement. The repeat
+environment exposed only a GameViewer virtual display. Current main reproduced
+the failure with its own App and capture binary: video/audio arrived, but video
+was roughly 4–7 fps without receiver loss or decode drops. A source-window draw
+counter advanced about 133 times per second; that counts drawing attempts, not
+Windows capture delivery.
+
+Temporary capture instrumentation observed about 3–4 WGC frame-arrival events
+per second. Queue draining was bounded in the observation, conversion/submission
+took less than a millisecond, and encoder diagnostics showed small encode times
+without CPU/quality adaptation. Bringing the source window forward did not
+restore cadence. A capture-only control, without the App or SFU, also delivered
+about 3.9 fps from both Chrome and a separate animated Windows Forms window.
+A separate minimal WGC program, using only Windows APIs and no Piik code,
+reproduced about 3.8 fps while that Forms window painted about 32 fps.
+Default, 4 ms and 1 ms capture intervals returned the same rate, including with
+the source kept topmost; property readback confirmed each requested interval.
+This localizes the limit to platform capture delivery in this environment but
+does not establish the virtual driver's cause or diagnose a field report.
+No capture scheduling, encoder policy or gate threshold was changed.
+
+Later on the same day, Windows reported an active physical AOC display. The
+unchanged Windows-API-only probe delivered about 32 fps, matching its GDI source,
+at the default, 4 ms and 1 ms intervals. The candidate's Native H.264 two-room
+SFU gate then passed the original 300-frame/30-second requirement, live profile
+change, decoded audio, subscription recovery and retirement. Stopping the first
+room preserved another 30 decoded frames and continuing audio in the second;
+all owned processes, profiles and ports were released. This closes that local
+throughput acceptance gap. It does not isolate the virtual driver's cause or
+establish physical HDR, game-load or other-device performance.
+
+The same capture-delivery limit recurred during the October 14 audio checks.
+At final local acceptance, the unchanged capture-only probe delivered about
+144 fps, and the default 720p Native VP8 and H.264 SFU gates passed their original
+throughput requirement, live audio/video changes, subscription and page-reload
+recovery, publication retirement and cleanup. The VP8 two-room check also kept
+the second room's picture and audio after the first stopped. No product or
+gate-threshold change was needed to clear this local acceptance failure; the
+intermittent platform cause and physical-device limits remain unproven.
+
## Native H.264 Motion Quality
Issue [#432](https://github.com/TNTcraftHIM/Piik/issues/432) reports more visible
@@ -451,9 +494,11 @@ Baseline alternatives. The original field quality cause remains unproven.
One independent compatibility defect was confirmed: capture admission compared
against `42c0` literally, rejecting equivalent Constrained Baseline SPS such as
-`42e0`. Windows validation and the shared Go capture-state reader now follow
+`42e0`. The three Native adapters and shared Go capture-state reader now follow
[RFC 6184 Table 5](https://www.rfc-editor.org/rfc/rfc6184.html#section-8.1), retaining
-the existing level bounds and rejecting actual Main/High. Windows active status
+the shared level bounds and rejecting actual Main/High. Linux's former output
+gate also omitted Level 5.2 and lower automatic levels; it now follows the same
+admission as its requested profiles. Windows active status
reads the emitted SPS instead of synthesizing a vendor-specific value. This is
not a profile upgrade or an explanation for `DXGI_ERROR_DEVICE_REMOVED`.
@@ -476,46 +521,372 @@ a corrected SDP interpretation, not evidence that all Firefox sharing lost audio
## Media Capability Extension Assessment
-Proposal assessed 2026-10-04 for a possible 2.0 phase; this does not change the
-current contract or the profile decision above. Resolution enums are enforced
+Planning direction reviewed 2026-10-09 for 2.0; the design below is a proposal,
+not a replacement for the current contract or profile decision above. The owner
+reopened numeric media requests and capability negotiation as one coherent
+extension. The published resolution enums are enforced
by both signaling readers and Native control, while capture admission separately
bounds dimensions. Native capture, forwarding and SFU declarations also admit
specific encoded formats. Removing one validator would leave these consumers
inconsistent.
-Source review also distinguishes output geometry from a quality limit. Windows
-`FrameConverter` fits content into the selected landscape raster with a black
-background, so its padding is encoded. Browser display capture instead requests
+Source review also distinguishes output geometry from a quality limit. The
+published Windows path fits content into the selected landscape raster with a
+black background, so its padding is encoded. Browser display capture instead requests
landscape width/height ceilings; a portrait source can be reduced unnecessarily
by the height ceiling. The shared TV uses `object-fit: contain` in a 16:9 stage,
adding presentation space independently of encoded padding. For example, the
-Windows fit rule places 1080x1920 content at 608x1080 inside a 1920x1080 output.
+published Windows fit rule places 1080x1920 content at 608x1080 inside a 1920x1080 output.
This is a code-derived result, not a new physical capture measurement. Evaluate
orientation-independent limits and preservation of source aspect ratio together;
adding numeric fields alone would retain the current geometry limitation.
-The restriction extends beyond resolution labels: shared readers also bound
-frame rate and bitrate, and Native's selectable profile admits four exact raster
-pairs while derived outputs admit other dimensions within axis-specific limits.
-These are different meanings. Keep explicit resource ceilings, but assess them
-separately from presets and device capability; arbitrary numbers are not the
-replacement contract.
-
-The smallest proposed extension separates UI presets from numeric media limits
-and uses existing WebRTC codec negotiation. The
-[Media Capture specification](https://www.w3.org/TR/mediacapture-streams/)
-distinguishes requested constraints, supported ranges and actual settings;
-[WebRTC capabilities](https://www.w3.org/TR/webrtc/#rtcrtpsender-interface)
-advertise optimistic codec support, not guaranteed hardware execution. Preserve
-actual encoder admission and validate dimensions, pixel counts, frame sizes and
-resource bounds at their existing owners.
+The candidate changes the existing Windows output worker: it passes the
+source's fitted even dimensions to WebRTC, retaining the selected profile as a
+ceiling. A 640x480 source stays 640x480; portrait and ultrawide sources no longer
+require an encoded landscape canvas. The converter, adaptation owner, output
+generation and frame envelope are reused. Axis-specific limits still apply;
+portrait capture can use explicit width/height ceilings but does not transpose
+the selected ceiling automatically. Browser evidence follows below.
+
+Linux uses the existing [GStreamer scaler](https://gstreamer.freedesktop.org/documentation/videoconvertscale/videoscale.html)
+with bounded even-dimension ranges instead of fixed output dimensions. Square
+pixel negotiation retains presentation aspect without a padded canvas; encoded
+samples provide the actual dimensions for the frame envelope. The headless
+control changes portrait, ultrawide, small, odd-sized and square inputs within
+one pipeline, checks the output ceiling and image corners, and exercises the
+smallest derived output. Compilation and those regressions passed on Linux;
+Portal capture and hardware encoder resizing still need a physical device.
+
+The macOS candidate now interprets ScreenCaptureKit's per-frame content rectangle,
+pixel scale and content scale, following Apple's
+[frame-metadata guidance](https://developer.apple.com/videos/play/wwdc2022/10155/).
+It retains the bounded capture surface, describes its content through CoreVideo's
+clean aperture and uses VideoToolbox's
+[source-aperture scaling](https://developer.apple.com/documentation/videotoolbox/kvtscalingmode_cropsourcetocleanaperture).
+Each existing output worker fits the unscaled source within its ceiling and
+replaces only its compression session when the fitted size changes. The frame
+envelope carries actual dimensions; declared output limits and room/route
+identity remain unchanged. Encoded-input derivation uses the same worker.
+
+The candidate passes macOS SDK compilation and packaged startup on Apple silicon
+and Intel in [CI run 38128668158](https://github.com/TNTcraftHIM/Piik/actions/runs/38128668158).
+The native check includes content-geometry validation and successive-shape
+hardware self-tests when an encoder is available. Runner checks do not replace
+real window resizing, Retina transitions, cropped image fidelity or quiet-source
+recovery on a physical Mac.
+
+Local synthetic-input controls exercised that production worker through four
+successive source sizes: 1080x1920, 3440x1440, 640x480 and 1920x1080. Under the
+1080p ceiling, H.264 and VP8 each produced 40 frames at 606x1080, 1920x802,
+640x480 and 1920x1080; Chrome decoded every frame at the declared dimensions.
+Separately, a codec-only control bypassed product profile admission and encoded
+1080x1920, 3440x1440 and 3840x2160 at 30 fps with the current Windows H.264
+encoder. Chrome decoded all 30 frames per size; observed levels were 4.0, 5.0
+and 5.1. This establishes local codec feasibility, not product 4K support,
+4K60, sustained performance or cross-device acceptance.
+
+The real Windows H.264 source also passed the existing App UI and two-Viewer
+gates: live/paused settings, background/window recovery, source replacement and
+retirement retained their expected owners. Native SFU passed 300 decoded frames
+at its original source aspect, reduction from 658x720 to 438x480, numeric audio
+changes, Viewer reload/recovery and cleanup. Gate expectations now derive from
+the observed source and selected ceilings, instead of requiring encoded 16:9
+padding. Physical game, macOS/Linux and orientation-change acceptance remain open.
+
+The candidate's Native capture admission now shares the numeric dimension
+bounds already used by encoded outputs instead of duplicating the four UI
+presets. A source ceiling must also yield a valid half-size output. Bitrate and
+output-count bounds remain unchanged. Candidate signaling and Native control now carry numeric
+width/height ceilings, with custom input beside the existing UI shortcuts.
+Descriptive wire bounds are independent of the current implementation maximum;
+App discovery advertises size, pixel-count, macroblock-rate, frame-rate and bitrate limits, and Native rejects unsupported
+size/rate combinations as operation failures without retiring the control
+session. Removing an enum is not permission to allocate arbitrary media resources.
+
+The Windows H.264 UI gate switches from 1080p60 to the 4K ceiling, then applies
+a non-preset 960x600 ceiling during the live share. Its earlier 30 fps run
+observed fitted output without replacing the Viewer media object. The gate now
+expects the 60 fps control and needs rerunning. The captured window is smaller
+than 4K, so this verifies settings/lifecycle, not physical 4K capture.
+Preset changes, paused updates, source recovery/replacement and subsequent
+sharing still pass. Value-based comparison prevents an identical deserialized
+size from turning an audio-only edit into a video update. Numeric preference
+round-trips and malformed/unsupported requests have separate boundary checks.
+
+Native bounds admit 3840x2160 and 2160x3840 at 60 fps. The per-axis limit and
+pixel total remain independent; an arbitrary 3840-square picture is rejected.
+The output throughput ceiling uses H.264 Level 5.2's macroblock rate, including
+alignment for non-preset sizes. Coded padding and decoded reference buffers have
+their own pre-allocation bounds, following [H.264 Table A-1](https://www.itu.int/rec/T-REC-H.264)
+(also implemented in [FFmpeg's level table](https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/h264_levels.c)).
+Level 5.2 raises throughput without raising Level 5.1's coded-frame or
+reference-picture storage bounds.
+
+A 2026-10-11 control corrected the earlier 4K30 conclusion: explicitly forcing
+Level 5.2 failed this hardware encoder's output-type setup, but leaving the
+level unset encoded the same 4K60 inputs as Constrained Baseline Level 5.2.
+[Microsoft recommends automatic level selection](https://learn.microsoft.com/en-us/windows/win32/medfound/h-264-video-encoder).
+The production configuration follows that advice and validates actual SPS tools
+and level through 5.2, retaining stable SPS identity and recovery-unit checks.
+Local Media Foundation hardware/software decode delivered all 60 frames in both
+4K orientations, with a passing 1080p60 control. Chrome also decoded all 60
+production-encoder frames per orientation; production H.264 and VP8 workers
+passed a 4K-to-1080p-to-4K source sequence at the 60 fps setting. The normal
+Go/native encoded-input path also derived lower outputs from these inputs.
+This disproves a blanket 4K30
+restriction; the combined encode/decode fixture does not establish sustained
+60 fps on every device. Microsoft's older decoder documentation lists
+[Level 5.1](https://learn.microsoft.com/en-us/windows/win32/medfound/h-264-video-decoder),
+so local success is not a cross-device guarantee.
+
+A separate Chrome 152 canvas/PeerConnection control decoded 3840x2160 near
+60 fps with both H.264 and VP8. Its H.264 SDP advertised Level 3.1, consistent
+with [WebRTC's built-in capability list](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/video_coding/codecs/h264/h264.cc).
+That static label alone does not establish a device's observed limit. Piik does
+not add an independent quality ladder or infer universal decoder support from
+codec matching; limited receivers and real routes still need acceptance.
+
+Decoder format checks must distinguish storage from visible content. On
+2026-10-11, the Windows decoder rejected a valid 3840x2160 picture stored in a
+3840x2176 surface because its output check applied the visible-pixel bound to
+codec padding. Media Foundation's [display aperture](https://learn.microsoft.com/en-us/windows/win32/medfound/picture-aspect-ratio)
+and CoreMedia's [clean aperture](https://developer.apple.com/documentation/coremedia/cmvideoformatdescriptiongetcleanaperture(_:originisattopleft:))
+own visible dimensions; both adapters now validate these separately from bounded
+coded storage. Windows copies the aperture while retaining the decoder surface,
+so downstream outputs receive the visible picture. The native regression checks
+right/bottom H.264 padding with real software decoding, visible edge pixels, and
+an offset NV12 aperture through the DXGI-buffer path. Oversized coded/reference
+storage remains rejected; the physical macOS boundary above still applies.
+
+An additional nonzero left/top SPS crop exposed a local Media Foundation software
+decoder limit: its raw output omitted right/bottom picture pixels before Piik's
+surface copy. FFmpeg decoded the same generated frame correctly. Removing the
+input-size hint or changing output-aperture attributes did not restore them.
+The passing ordinary alignment-padding controls do not establish arbitrary SPS
+crop support on that decoder; no bitstream rewrite or replacement decoder is
+introduced for this unreported platform edge case.
+
+Current production Windows code encoded and decoded 30 synthetic frames in each
+4K orientation, both with and without a D3D decoder manager. Retaining/uploading
+the decoded surface also passed. The normal Go/native encoded-input path then
+produced 1920x1080 and 1080x1920 derived outputs. Production H.264 and VP8 output
+workers also handled a 4K-to-1080p-to-4K synthetic source sequence, and Native
+VP8 derivation produced the lower output from the 4K input. The Browser H.264 SFU gate with
+a synthetic 3840x2160/30 source passed first-picture delivery, an explicit lower
+profile, audio-only edits with retained connections, subscription/page recovery
+and retirement. Windows native checks and Linux headless build/checks pass;
+macOS SDK and physical 4K capture are not verified here. Controls now use the
+combined limits, reject oversized pixel totals and cap frame rate on size changes.
+
+Chrome 154 real portrait/landscape tab capture retained the source aspect through
+the pooled output. The landscape check also passed live/quiet/paused profile
+changes, source replacement and ordinary recovery after producer failure.
+A synthetic 2160x3840 Browser relay delivered fresh pictures to two children,
+including a late connection. Separate 3840x2160 and 2160x3840 Browser-to-Native
+fanout checks delivered about 30 fps to both Viewers and passed live reduction,
+pause/resume, source-audio changes and App-exit recovery. Reproduce with
+`PIIK_CLIENT_BROWSER_FANOUT_GATE=true`, `PIIK_CLIENT_GATE_VIDEO_SIZE=WIDTHxHEIGHT`
+and `CHROME_PATH` set, then run `npm run gate:app-browser-fanout`.
+These local checks do not establish 4K60, physical 4K/game
+capture, sustained device performance, all Browser decoders or all route changes.
+
+The existing SFU gate accepts `PIIK_EMBEDDED_SFU_VIDEO_SIZE=WIDTHxHEIGHT` for a
+bounded 60 fps / 50 Mbps source; its default 720p15 control remains unchanged. Run
+`npm run gate:embedded-sfu` with `PIIK_EMBEDDED_SFU_GATE=true`,
+`PIIK_EMBEDDED_SFU_CODEC=h264` and `CHROME_PATH`; add
+`PIIK_EMBEDDED_SFU_DISPLAY=true` for actual tab capture. Synthetic 2160x3840 and
+real 3440x1440 tab sources passed decoded-picture delivery, aspect-preserving
+reduction, numeric audio updates, subscription/page recovery and retirement.
+The tab control also passed background capture, lower-layer demand, pause/resume
+and source replacement. A separate full-UI 2160x3840 control passed SFU-to-P2P
+commit and failed-P2P-to-SFU recovery through the room controller, keeping the
+old SFU picture during peer preparation. The mixed Browser Host/Native Viewer
+control also passed those transitions. With clarity priority, its live
+2160x3840-to-360x640 edit retained the Native connection and playback track;
+SFU recovery retained that size and then restored 2160x3840. Firefox 155 received
+the same VP8 and H.264 sizes from Chrome 154, retaining its track through both
+edits; H.264 also passed through embedded SFU. All controls retired their
+receiving connections on stop. Balanced priority may deliver smaller adapted pictures.
+These local checks do not establish physical
+4K capture, every mixed handoff or cross-network performance.
+
+The H.264 control supplies OpenH264 2.6.0 to an isolated Firefox profile, using
+the artifact and SHA-512 from [Mozilla's codec manifest](https://github.com/mozilla-firefox/firefox/blob/main/toolkit/content/gmp-sources/openh264.json).
+This follows the [Web Platform Tests setup](https://github.com/web-platform-tests/wpt/blob/master/tools/wpt/browser.py).
+A fresh-profile control did not advertise H.264 and could not receive the
+H.264-only share, so codec availability must be established before attributing
+that result to resolution or networking. Firefox owns its component installation;
+the [upstream report](https://bugzilla.mozilla.org/show_bug.cgi?id=1766638) records
+the separate first-use installation boundary.
+With H.264 unavailable, the Browser accepts the offer but rejects its video
+section in the answer. The shared receiving check uses this
+[JSEP rejection](https://www.rfc-editor.org/rfc/rfc8829.html#section-5.3.1), rather
+than inferring support from a codec list. P2P and SFU controls now retain that
+cause through route exhaustion instead of reporting only a media-route failure;
+the Native local bridge uses the same check and its existing failure path.
+An answer rejection alone does not identify the decoder or platform cause.
+
+Chrome 154 camera controls exposed a separate constraint bug: lowering a
+540x960 input to the 1280x720 ceiling produced a cropped 540x720 picture when
+both axes also carried `ideal` values. Maximum-only constraints produced
+405x720, retained portrait shape through a subsequent 360x640 setting, and
+restored 540x960. Camera startup and picker requests could likewise crop to their
+ideal axes; they now share an uncropped device-format request (`resizeMode:
+{ exact: "none" }`). Portrait thumbnails then measured 180x320 instead of 320x180.
+A landscape control retained shape within rounding. Shared live constraints use
+ceilings without a fixed aspect ratio or rotation listener, following the Media
+Capture distinction below. Synthetic devices do not establish physical phone
+orientation behavior.
+
+The boundary follows the
+[Media Capture specification](https://www.w3.org/TR/mediacapture-streams/#constrainable-interface):
+requested constraints, supported capabilities and actual settings have different
+owners. Capability ranges cannot describe every combination of resolution and
+frame rate; [WebRTC codec discovery](https://www.w3.org/TR/webrtc/#rtcrtpsender-interface)
+is optimistic and reserves no hardware resources. An advertised capability is
+therefore not proof that a particular encoder configuration can start.
+
+Wire representation follows the platform types for dimensions/bitrates and a
+positive finite frame rate; current capture admission remains narrower. This
+allows a reader to reject an unsupported operation without rejecting its room
+or control session. Fractional rates must not be silently truncated by Native's
+integer capture API. SFU metadata passes the same representation boundary and
+then its existing bounded admission before allocation. Capability descriptions
+may exceed local support, but cannot authorize larger local allocations.
+This separation also appears in
+[mediasoup RTP parameters and capabilities](https://mediasoup.org/documentation/v3/mediasoup/rtp-parameters-and-capabilities/).
+
+The operating-range review found that the inherited 15 fps / 2 Mbps
+source minimum and 12 Mbps maximum retained the earlier menu range; they were not
+codec limits. [LiveKit's screen-share presets](https://github.com/livekit/client-sdk-js/blob/main/src/room/track/options.ts)
+include 720p5 at 800 kbps. [YouTube's H.264 live-ingest guidance](https://support.google.com/youtube/answer/2853702?hl=en)
+recommends 50 Mbps for 4K60, but its ingest/re-encoding workload is not a Piik
+WebRTC operating policy. These references motivate testing both ends of the
+range, rather than inheriting the old controls or copying another service's cap.
+
+The initial numeric candidate admitted 1..60 integer fps and 0.1..50 Mbps. Its
+lower-output construction caps the usual 150 kbps floor by the source ceiling, in both Browser
+SFU and Native. Browser and Native preflight require positive encoded progress;
+the old one-frame tolerance could otherwise pass zero output at 1 fps.
+An earlier raw H.264 encode/decode control established local feasibility beyond
+the old range. Production Windows output-worker checks now cover H.264/VP8 at
+720p1/100 kbps, 720p5/800 kbps and 4K60/50 Mbps, including source-size replacement.
+Native encoded input also produced the expected half-size output at the low and
+high bounds. Each derivation retained its existing input-format generation;
+source changes still retire and replan that optional output through its owner.
+Browser loopback with the real sender controls also delivered decoded progress
+at all three settings and after replacing the 1 fps source. At 1 fps / 100 kbps,
+the production Browser HostPeer also delivered to two local receivers through
+ordinary senders and the shared encoding pool, for both H.264 and VP8. This
+bounded check does not cover room routing or physical-network recovery. The 1 fps H.264
+preflight passed; the 4K60 preflight selected VP8 on this machine even though
+explicit H.264 delivered frames. Admission is not a sustained-cadence guarantee.
+
+The Windows Native Host gate now exercises the actual numeric UI at 1 fps /
+100 kbps and 60 fps / 50 Mbps with both codecs. Fresh decoded frames retain the
+same Viewer stream through those changes, 4K/custom ceilings, pause, source
+replacement and Viewer recovery. Its generated window is smaller than 4K;
+this establishes ceiling application and continuity, not physical 4K capture.
+The Browser H.264 and VP8 SFU controls request 60 fps from actual 3840x2160
+synthetic input, decode high and lower outputs, retain the connection across
+both rate bounds and audio updates, and recover subscriptions after reload.
+Native WGC-to-SFU controls also pass both codecs and rate changes with the same
+connection; their source is smaller than 4K. Positive output does not establish
+sustained 60 fps on arbitrary devices or networks.
+
+A 3840x2160/60 Browser-to-Native fanout control failed the existing 30 fps
+performance threshold: its source produced about 32..33 fps and the two Viewers
+about 27 fps. A separate functional continuation retained that negative result
+and passed live quality changes, pause/resume, source-audio replacement and
+App-exit recovery. That run measured about 41 and 37 fps at the Viewers, still
+below the requested 60 fps. The controls establish continuity on this machine,
+not sustained 4K60 cadence; they do not justify custom quality floors or resets.
+
+The current Linux candidate also passes C11 compilation with warnings as errors
+and the headless GStreamer regressions, including low/high rate admission,
+source geometry, retirement and distinct Portal node-ID/serial targeting.
+This uses GStreamer 1.28.3 and libportal 0.9.1 on an isolated Alpine runner;
+its probe correctly reports no capture without a desktop session. Actual
+Portal capture and platform packaging remain separate checks.
+
+A uniform-noise 4K1/50 Mbps stress input reached the retained 4 MiB access-unit
+guard in both Native codecs; VP8 produced a 6,199,837-byte frame. The output was
+rejected rather than expanding allocation limits. Large bitrate/low-cadence
+requests are not permission for unbounded frames. These local controls establish
+useful operation and bounded failure, not sustained field quality or complete-route
+performance. Ignored controls and results are under
+`build/candidate-review/four-k60/` (`range-worker.cpp`, `range-browser.js`,
+`low-rate-pool.js`).
+
+The 2026-10-13 extension uses the existing H.264 Level 5.2 budget instead of
+the earlier menu maximum: see [FFmpeg's level table](https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/h264_levels.c).
+The Windows production worker delivered H.264 and VP8 output for 1080p120/
+80 Mbps, 1440p144/100 Mbps and 1080p240/240 Mbps, including small-source changes
+and restoration. VP8 also delivered 5120x1440/60 at a 100 Mbps ceiling; this
+machine's hardware H.264 candidates rejected that width. These short synthetic
+checks establish useful output and retirement, not sustained cadence or bitrate.
+Browser WebRTC loopback also retained the requested sender limits and continued
+decoding all four sizes/rates with H.264 and VP8. These controls used synthetic
+canvas input; they do not establish screen-capture cadence or cross-device support.
+Setting both Windows decoder axis ceilings to 8192 made an existing 4K fixture
+fail at `BEGIN_STREAMING` with `E_OUTOFMEMORY`. Retaining platform provisioning
+defaults passed the same padding/visible-crop regression; SPS and output buffer
+guards remain in their existing owners. Raw H.264 1080p120/80 Mbps also decoded
+30/30 pictures. Both macOS and Linux architectures pass SDK compilation and
+geometry/profile checks; higher-range physical-device acceptance remains open.
+Local controls are in ignored `build/quality-polish/`.
+
+[WebRTC statistics](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatepairstats-availableoutgoingbitrate)
+describe estimated available capacity, not a configured send-rate ceiling;
+[Pion exposes it as a floating-point observation](https://github.com/pion/ice/blob/main/stats.go).
+Local in-memory Native control checks reproduced a 100 Mbps observation ceiling
+closing the entire connection. The corrected readers preserve high valid
+estimates, normalize invalid optional measurements and discard invalid quality
+samples without granting them control authority. Shared Go/TypeScript fixtures
+also cover sender, publication and receiver evidence. This establishes boundary
+behavior, not the cause of an unmatched field report or an improvement to ICE.
+
+| Meaning | Piik owner and representation |
+| --- | --- |
+| Quick presets | UI-local labels mapping to numeric picture/audio requests; adding a label does not extend the wire |
+| Host intent | One validated numeric size, frame-rate and bitrate request, distinct from measured output |
+| Available capability | Existing capture/receive/forward adapters report supported operations and bounded formats; unknown descriptive formats are unavailable |
+| Applied configuration | The existing share owner commits an admitted result under its current generation; failed or stale changes do not become saved preferences |
+| Live output | Existing capture and receiver evidence; WebRTC/LiveKit still own network adaptation |
+
+Dimensions, pixel counts/rates, encoded-frame bytes, allocations and concurrent
+outputs remain bounded before resource use. Remote capability claims cannot raise
+local limits. Reject malformed values and unauthorized commands; unsupported
+well-formed requests need a scoped failure, not an unrelated room disconnect.
+Use the existing generation and route operation, not a capability polling loop,
+general constraint solver or second recovery owner. Numeric limits describe
+ceilings, not a promise of a constant rate or resolution. A receiver's limits
+constrain its edge or existing local output group, not every Viewer in the room.
+
+Receiver-limit acceptance cannot rely on SDP matching alone. Chrome 154
+advertised H.264 Level 3.1 while decoding the 4K controls above. In isolated
+ordinary-sender checks, lowering the answer to Level 1.2, or adding VP8
+`max-fs=1200;max-fr=15`, did not reduce the received picture size. This is not
+evidence for a physical low-capability decoder. [RFC 6184's receive-level
+rules](https://www.rfc-editor.org/rfc/rfc6184.html#section-8.2.2) and actual
+framework behavior therefore need separate validation before promising
+automatic compatibility with smaller future capability sets.
Codec support must distinguish encoding, decoding and encoded forwarding,
-including profile/packetization and SFU admission. AV1 and VP9 are candidates to
+including profile/packetization and SFU admission. Reuse SDP codec matching;
+describe only Native control and forwarding limits that it cannot express,
+instead of negotiating the same format twice. AV1 and VP9 are candidates to
evaluate, not promised product formats. A late Viewer or relay that cannot
receive the current encoded source needs an explicit policy; SDP negotiation
alone cannot convert that source. Per-Viewer transcodes or room-wide codec
-switches are not implied by this proposal.
+switches are not implied by this proposal. This is also explicit in
+[mediasoup's producer/consumer capabilities](https://mediasoup.org/documentation/v3/mediasoup/rtp-parameters-and-capabilities/).
+[LiveKit's backup codec policy](https://github.com/livekit/client-sdk-js/blob/main/src/room/track/options.ts)
+uses another encoding or switches the publication; that compatibility has real
+CPU/bandwidth or continuity costs. Piik's embedded LiveKit components do not
+automatically provide its complete SDK policy. Keep H.264/VP8 as the initial
+compatibility path; admit a new format only with its whole-route evidence.
Current discovery couples format to implementation (`hardwareH264` and
`softwareVP8`), and Browser preference uses a VP8-specific fallback flag.
@@ -534,6 +905,50 @@ inherently require a major; breaking an existing public contract does. Evaluate
the Browser/App/Server and mixed-peer flows before freezing a replacement
contract; avoid a general compatibility framework.
+### Audio And UI Proposal
+
+[#462](https://github.com/TNTcraftHIM/Piik/pull/462) requests 256/320 kbps Opus.
+The values are permitted by [RFC 7587](https://www.rfc-editor.org/rfc/rfc7587.html#section-6.1),
+whose receive-rate parameter spans 6,000..510,000 bit/s. This is a codec boundary,
+not a recommended default or proof of audible benefit. The current preset names
+also travel in authentication and Native commands: adding its two enum values
+causes the actual v1.9.2 readers to terminate those sessions. The PR's compatible
+release claim is therefore incorrect even though its audio use case is valid.
+
+The 2.0 candidate uses one numeric audio-bitrate ceiling through capture, sender,
+receiver and SFU; derive existing preset labels locally. Align receive admission
+with codec support instead of the largest UI shortcut. Keep the existing default
+while comparing actual Opus output and listening results for higher ceilings.
+The [numeric audio controls](./browser-screen-audio-quality.md#numeric-bitrate-assessment)
+establish local 256/320 kbps encoding and Browser reception, with separate
+receive-limit enforcement and a 320 kbps implementation ceiling. Full-range
+Opus wire validation remains independent of that current packet-size choice.
+[LiveKit's AudioPreset](https://docs.livekit.io/reference/client-sdk-js/interfaces/AudioPreset.html)
+likewise contains a numeric maximum bitrate; its
+[high-fidelity example](https://docs.livekit.io/transport/media/advanced/#hi-fi-audio)
+also considers stereo, source processing and redundancy. Raising bitrate alone
+does not establish studio quality, losslessness or multi-channel output.
+
+Keep Piik's compact presets and existing picture/sound settings disclosure.
+Expose custom numeric values with units and valid ranges there, without adding
+a permanent row for each new preset. Use supported-format selection for codecs,
+not an arbitrary text field. Preserve source aspect ratio without upscaling;
+distinguish selected limits from actual capture/decoded dimensions in existing
+details. Capability-related restrictions explain a real limitation briefly.
+The [OBS settings guide](https://obsproject.com/kb/obs-studio-overview#settings)
+supports a simple/default entry with further controls; Piik need not reproduce
+OBS's full output configuration. Existing visual/tooltip owners still apply,
+including narrow screens, both themes and visual mode.
+
+Start with numeric audio through the existing pipeline, then portrait/ultrawide
+geometry and 4K as a second concrete use case. A 4K path must review all platform
+capture/decoder bounds, encoded-frame limits and SFU admission. Admission and output must be tested
+at the actual profile, not inferred from a codec list. Check live changes,
+late/limited Viewers and relays, failure cleanup and resource rejection before
+freezing the 2.0 contract. Prefer a clear 1.x mismatch over a general legacy
+adapter. Future presets inside supported numeric semantics need no new enum;
+new formats still require actual implementation and an advertised capability.
+
## Implementation Boundary
- `nativecapture` owns the child process, source identity, and bounded frame protocol.
diff --git a/docs/research/network-lab.md b/docs/research/network-lab.md
new file mode 100644
index 000000000..37beb91f6
--- /dev/null
+++ b/docs/research/network-lab.md
@@ -0,0 +1,247 @@
+# Controlled Network Laboratory
+
+This document owns the laboratory's method and evidence limits. Product routing
+remains in [routing and transport](../standards/routing-transport.md); pending
+work remains in [TODO](../todo.md). The laboratory does not authorize NAT or
+adaptation changes.
+
+## Design And References
+
+Reviewed on 2026-10-13:
+
+- [Tailscale NAT Lab](https://github.com/tailscale/tailscale/issues/13038)
+ combines precise in-memory models with real systems attached to a virtual
+ Internet. Keep both layers: a model regression cannot establish complete App
+ or Browser behavior.
+- [WebRTC Network Emulation Framework](https://webrtc.googlesource.com/src/+/HEAD/test/network/g3doc/index.md)
+ separates endpoints, routes, traffic behavior and measurements. Preserve
+ directional links and distinguish simulated time from real-process timing.
+- [Mahimahi, ATC 2015](https://www.usenix.org/conference/atc15/technical-session/presentation/netravali)
+ motivates isolated, reproducible network conditions around real applications.
+- [CellReplay, NSDI 2025](https://www.usenix.org/conference/nsdi25/presentation/sentosa)
+ shows that replay can be workload-sensitive. Synthetic delay/loss profiles are
+ named impairments, not validated replicas of a carrier or Wi-Fi network.
+- [MirrorNet, NSDI 2026](https://www.usenix.org/conference/nsdi26/presentation/miao)
+ reinforces checking control, data and traffic together. Piik needs that
+ consistency, not a large WAN orchestration platform.
+- [RFC 4787](https://www.rfc-editor.org/rfc/rfc4787) separates UDP mapping from
+ filtering. Describe both; a random-port Linux SNAT rule alone does not prove a
+ named consumer-router NAT type.
+- [Linux network namespaces](https://man7.org/linux/man-pages/man8/ip-netns.8.html),
+ [nftables NAT](https://wiki.nftables.org/wiki-nftables/index.php/Performing_Network_Address_Translation_(NAT))
+ and [netem](https://man7.org/linux/man-pages/man8/tc-netem.8.html)
+ provide the real-process substrate without another product dependency.
+
+## Acceptance Contract
+
+The laboratory is complete only when the following are executable and verified:
+
+1. Isolated Host, Viewer, routers and Site run real processes. Only laboratory
+ namespaces, profiles and files are mutated; ordinary routes/firewall rules
+ remain unchanged. Cancellation and failed setup clean up owned resources.
+2. Existing Pion model tests retain precise mapping/filtering and socket-transfer
+ coverage. Real-network calibration independently checks the configured NAT,
+ reachability, delay, rate and loss before attributing an outcome to Piik.
+3. Browser/Browser, App/Browser, Browser/App and App/App paths use the actual UI,
+ signaling and media owners. A generated picture/tone replaces physical capture
+ only; no route, SDP, candidate, timeout or readiness is synthesized.
+4. Scenarios cover discovery failure, different NAT behavior, asymmetric paths,
+ finite outage, address/mapping changes, constrained/recovered media, a healthy
+ sibling, signaling-only interruption, SFU fallback and bounded total failure.
+ IPv4/IPv6 are distinguished. Unsupported environments fail preflight rather
+ than silently omitting required cases.
+5. Success requires decoded, progressing media and feedback, not merely an ICE
+ connection or route message. Record first-picture and recovery times, media
+ dimensions/cadence, loss, selected path, UI state and cleanup. Unmatched
+ measurements and timed-out cases remain failures, never zero-valued successes.
+6. Repeatable scenario inputs, software/kernel identity, seeds, observed fault
+ counters and bounded timelines accompany a structured report. Paired
+ enhancement on/off comparisons use the same scenario and endpoints. Report
+ counts and distributions without presenting a model pass rate as Internet
+ success probability.
+7. A documented command and opt-in Linux CI entry run the same checks. Exercise
+ failed/cancelled runs as well as passing runs; verify that a deliberately
+ missing fault or broken media path cannot produce a passing report.
+
+## Boundaries
+
+The fast layer already uses Pion vnet in `internal/app/mediaedge` and
+`internal/media/forwarding`. It exercises production transport with synthetic
+media, not every Browser/OS behavior. The process layer uses Linux namespaces,
+kernel NAT/firewall rules and traffic shaping. Neither layer establishes
+Windows/macOS driver behavior, physical radio scheduling, mobile suspension or
+the distribution of real-world NATs. The current process matrix uses desktop
+Chrome and a Linux App with Browser-generated H264 ingress; it does not exercise
+physical capture, other Browser engines, real gateway mapping or the public
+invitation provider.
+
+Actual packets/counters must corroborate each impairment. DevTools accepting a
+network-emulation request is insufficient: an earlier Piik check observed RTP
+outside the requested limits. Keep negative controls, finite deadlines and
+resource limits as part of the laboratory rather than changing product recovery
+to make an experiment pass.
+
+## Running The Process Layer
+
+Use an isolated Linux machine or VM with Node/Go from the repository toolchain,
+Google Chrome, current `iproute2` with netem `seed` support, `nftables`,
+`conntrack` and `tcpdump`. CI builds a pinned iproute2 6.17 traffic-control binary locally because
+the runner's distribution package lacks that option. Namespace administration
+requires root. Windows/macOS can run the model/unit layer; they need a Linux VM
+for this process layer. Root on a production host is not an equivalent setup.
+
+```sh
+npm ci
+npm run build:web
+mkdir -p build/network-lab/bin
+CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/piik-server ./cmd/piik-server
+CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/piik-app ./cmd/piik-app
+CGO_ENABLED=0 go build -trimpath -o build/network-lab/bin/stun ./scripts/network-lab/stun
+sudo --preserve-env=PATH,CHROME_PATH "$(command -v node)" --import tsx scripts/network-lab.ts --suite smoke
+```
+
+Set `CHROME_PATH` to the installed Chrome executable before running. `--list`
+lists scenarios without root; `--suite full`, `--cases browser-browser,app-app`,
+`--repeat 3` and `--seed 42` select reproducible inputs. Netem seeds do not make
+browser scheduling, kernel port allocation or real-time timings deterministic.
+`--prediction compare --repeat 3` pairs the existing Host enhancement switch on
+and off with the same inputs and alternates run order. `distributions.json`
+reports trial/failure counts and nearest-rank timing summaries for validated
+media trials; unavailable and timed-out attempts never become zero-millisecond
+successes. Small trial counts are observations, not population estimates.
+`--verify` (CI `network_lab: verify`) runs negative controls against the actual
+runner: missing fault, frozen picture (before and after an impairment), partially failed setup, and cancellation
+during calibration or sharing. Each must fail for its intended reason and leave
+no owned resources; an unrelated process failure is not a passing control.
+Each fault phase replaces its owned qdisc completely: netem's in-place change
+retains some omitted attributes, including a previous rate limit. Restoration
+therefore checks the resulting rules and independent UDP reachability as well as
+the resumed media. Recovery has its own picture/audio/feedback observation window.
+The manual CI workflow's `network_lab` input runs this same entry point and never
+publishes. Reports and bounded synthetic-traffic captures are under ignored
+`build/network-lab/results/`; CI retains them for seven days.
+
+The `quality-peer`, `quality-sfu-uplink` and `quality-sfu-downlink` cases use the
+same moving 720p Browser source. They establish 45 seconds of baseline, constrain
+one direction to 600 kbps for 30 seconds, then observe 90 seconds beyond resumed media. The
+final observation must retain the original connection and source resolution for
+at least ten seconds, with advancing pictures and audio. This is an
+unconstrained-fixture recovery check, not a product resolution floor or proof of
+game quality. Frame cadence, bitrate, loss, repair, freezes and QP remain separate
+measurements. These longer cases cap captures at 500,000 packets, retaining only
+each packet's first 96 bytes; ordinary cases keep their 100,000-packet bound.
+
+The [full process acceptance](https://github.com/TNTcraftHIM/Piik/actions/runs/38505151190)
+on 2026-10-14 passed all 37 scenarios and six negative controls on candidate
+`a5e89805`, including resource cleanup and unchanged host networking. Thirty-one
+scenarios produced advancing decoded audio/video. Blocked UDP, the two
+random-mapping profiles and the three Browser-containing sequential-mapping
+cases produced bounded unavailability; there were no observation timeouts.
+A passing failure observation is not a
+successful connection or an Internet success-rate measurement.
+The [paired comparison](https://github.com/TNTcraftHIM/Piik/actions/runs/38301455274)
+also passed eight trials across two seeds with alternating enhancement on/off
+order. All four ordinary App/App trials delivered media; all four random-mapping
+trials exhausted their routes. This validates comparison/reporting and establishes
+no enhancement gain for that random-mapping profile.
+
+The [sequential-mapping comparison](https://github.com/TNTcraftHIM/Piik/actions/runs/38433597605)
+passed sixteen trials across two seeds and all four endpoint pairs. Both enhanced
+App/App trials delivered video and audio after about 23.6 seconds; both disabled controls exhausted
+their routes. Both Apps reported step-one observations, successful preparation
+and socket adoption for the same connection. Packet captures confirmed SRTP and
+reverse SRTCP on that tuple. The enhanced App/App case now requires media; its
+disabled control remains observational so an ordinary-ICE improvement can pass.
+Browser/Browser and both mixed directions exhausted their routes with either
+setting in this profile. This establishes one complete product path through sequential
+kernel NAT, not a field success rate or general benefit for Browser participants.
+
+The [mixed-endpoint recovery comparison](https://github.com/TNTcraftHIM/Piik/actions/runs/38433591403)
+passed eighteen trials: three address-change runs per endpoint pair and three
+ordinary Browser/App and App/App connections each. Browser/App recovery took
+about 4.9 seconds, including the three-second continuity window, compared with
+13..32 seconds [before the repair](https://github.com/TNTcraftHIM/Piik/actions/runs/38430143831).
+The Native receiver had removed RTX during codec selection. Packet captures
+showed repeated old primary packets before the fix and negotiated RTX without
+repeated primary sequence numbers afterward. Pion's existing
+[RFC 4588](https://www.rfc-editor.org/rfc/rfc4588) repair handles reception;
+no new recovery timer or relaxed replay check was added. The other endpoint pairs
+retained their prior recovery range. These controlled observations establish a
+media-recovery repair, not a change in NAT reachability or Internet success rate.
+The [shared-receiver acceptance](https://github.com/TNTcraftHIM/Piik/actions/runs/38439642180)
+adds three SFU-only outage trials, three SFU fallback trials and three further
+Browser/App address-change trials. All delivered media and cleaned up their
+resources. SFU outage recovery took about 4.9 seconds; repeated Browser/App
+recovery ranged from 4.9 to 11.1 seconds, so 4.9 seconds is not a guaranteed bound.
+The SFU-only fixture verifies the locked server-media control and disabled
+NAT policy; it does not attempt to enable a hidden P2P setting.
+
+App Host checks must use the real H264 Browser-to-App ingress and App WAN sender;
+launching an App alongside a Browser-only VP8 share is not an App-path test.
+
+Each router uses destination-aware connection tracking for filtering. Independent
+probes check responses from the contacted endpoint, another port and another
+address before sharing. Preserve, random and sequential SNAT describe observed
+mapping, not every consumer NAT implementation. Sequential cases use nftables'
+number generator and a typed port map: new UDP flows advance through ports
+40000..44095, with the same destination-dependent filter. Calibration must
+observe three consecutive mappings before Piik starts. All four endpoint pairs
+compare the existing enhancement switch; this switch also
+changes the retry budget, so aggregate differences do not isolate one mechanism.
+The recorded Native preparation and adoption results distinguish its use from
+ordinary ICE. Address-change cases replace only the isolated router's UDP public
+address and conntrack entries while proving that TCP signaling stays usable;
+both old and new media windows retain their respective addresses for
+packet/feedback checks.
+IPv6 cases provide a Pion-based external STUN endpoint as well as IPv4 embedded
+STUN. Browser mDNS host names cannot resolve across these routed networks;
+keep normal Browser privacy behavior and obtain IPv6 reflexive candidates through
+STUN. The App may use its ordinary IPv6 host candidates. IPv6 cases block IPv4
+UDP and independently verify that block; they do not require a Browser to try a
+blocked IPv4 candidate before selecting working IPv6. Continuity checks require
+changing decoded pictures and advancing audio energy; cadence and dimensions are
+measured separately, without imposing a new minimum on WebRTC adaptation.
+App checks require current Native connection state and a local Browser bridge in
+both initial and recovered media windows; a Browser WAN fallback cannot validate
+Native recovery. Rate calibration requires offered traffic above the configured
+ceiling, so a slow test process cannot impersonate a constrained network.
+Recovery timing begins when the impairment is removed, including subsequent
+calibration and sampling time. Recovery requires a new advancing-picture sample
+followed by a full three-second audio/video continuity window and the current
+Native path where applicable. A buffered burst followed by another stall keeps
+the original 60-second observation deadline; it is not a successful recovery.
+`firstPictureMs` starts at Viewer navigation and
+ends at the first sample with at least 15 decoded frames; sampling is every
+500 ms. Recovery checks poll every second. These are sampled media-admission
+times, not exact first-packet timestamps or one-way media latency.
+
+## Sustained Quality Recovery
+
+The [2026-10-14 comparison](https://github.com/TNTcraftHIM/Piik/actions/runs/38446966925)
+passed two trials for each quality case on candidate `ea2c7268`, using Chrome
+154.0.8037.97 and the moving 720p30 VP8 source. All six retained their connection,
+restored source resolution and cleaned up. Times below start at impairment
+removal; resumed media includes the existing three-second continuity check.
+Source-resolution recovery is measured separately from resumed decoding.
+
+| Constrained path | Resumed media | Sustained 720p |
+| --- | --- | --- |
+| Browser P2P downlink | 4.9 s | 45.3–47.2 s |
+| Host-to-SFU uplink | 17.0–19.0 s | 28.1–29.1 s |
+| SFU-to-Viewer downlink | 11.9–13.0 s | 22.0 s |
+
+Final twenty-second windows delivered 29.3–29.4 decoded fps with no new freezes.
+The impaired SFU paths did pause, and the Browser encoder's bitrate/quantization
+recovered gradually after dimensions returned. These results do not establish
+instantaneous or perceptually lossless recovery. The forwarding SFU can pause
+when no received layer fits a child's capacity; it does not create a lower
+encoding for that child.
+
+The v1.9.2 report of persistent self-hosted degradation has no matched trace,
+codec or confirmed route. Piik already forwards encoded video without server
+decoding/transcoding. An opaque UDP relay changes transport feedback and delivery
+semantics; its reported improvement does not isolate CPU, network or an SFU cause.
+The [queue/probe repairs](./node-local-media-design.md#send-queue-recovery) address
+confirmed mechanisms present in v1.9.2, but this run is candidate acceptance,
+not a matched old/new reproduction of that report. It does not measure a cloud
+server's CPU requirement, H264 behavior, multi-Viewer load or hours-long stability.
diff --git a/docs/research/node-local-media-design.md b/docs/research/node-local-media-design.md
index 03a8d1176..79aa47218 100644
--- a/docs/research/node-local-media-design.md
+++ b/docs/research/node-local-media-design.md
@@ -212,6 +212,15 @@ The replaced local Group/
Selection and receiving-PC sender-estimator registry are deleted, not retained
as alternate implementations. There is no mirrored counter summing unused layers.
+Source subscription must retain layer discovery that happens during registration.
+LiveKit's `ReceiverBase.AddDownTrack` delivers initial layer metadata before
+registering the subscriber, leaving a notification gap. The shared Source
+refreshes that metadata after successful registration; DownTrack keeps its
+highest observed layers, so a concurrent newer notification cannot be undone.
+This boundary serves both Native outputs and SFU publication. A controlled
+discovery in that gap reproduces the failure without retries or network loss;
+it does not establish the cause of unmatched field reports.
+
The parent reads child state at actual capture InputBegin. A controlled layer
switch uses the existing DownTrack resync before outputs for that input, retaining
its target; this prevents an old-layer packet winning the same presentation time.
@@ -223,8 +232,8 @@ input anchor, not independently to each encoder's completion time.
Local capture replacement keeps its RTP source and cumulative counters while
rebasing only the input packetizers. It must not also restart the RTP projector.
-Unconnected edges reserve capacity without asking for output. Existing audio
-settings or observed relay-audio bytes reserve that portion of the PC budget.
+Unconnected edges reserve child slots without asking for output. Video capacity
+comes from its tracked traffic; untracked audio must not be subtracted again.
Capture producers now implement the coordinated v5 multi-output boundary below;
the packaged runtime has not been physically accepted. A bounded real libvpx
@@ -386,19 +395,87 @@ fixes and demand hooks beyond a thin adapter; do not maintain both candidates.
Keep the dependency behind a shared media package. For one video per connection,
use the public provisional allocation primitives, with exact typed child state
read by its parent; do not parse DebugInfo or run a second multi-track allocator.
-The candidate now uses LiveKit SendSideBWE and Prober through their public APIs.
-Pacing reuses the upstream queue with admission capped by the existing packet
-cache bound; canceled probe tickets remain charged until actually dequeued.
-The pinned Pion pacer's 1.5 headroom applies only to pacing, not codec or BWE
-budgets. Adapted probe lifecycle code retains Apache-2.0 attribution. Probe
+The candidate uses LiveKit SendSideBWE and Prober through their public APIs,
+with the [scoped measurement repair](../../internal/thirdparty/README.md#livekit-send-side-bandwidth-estimation).
+Packet sending uses the pinned [LiveKit transport's send-side BWE default](https://github.com/livekit/livekit/blob/v1.13.6/pkg/rtc/transport.go),
+`NoQueue`: an asynchronous writer without a second bitrate throttle on already
+encoded packets. Admission stays capped by the existing packet cache bound;
+canceled probe tickets remain charged until actually dequeued. Congestion
+estimation, probes and codec/allocation budgets retain their existing owners.
+Adapted probe lifecycle code retains Apache-2.0 attribution. Probe
promotion requires the upstream validity criterion and non-congestion signal;
an empty or inconclusive attempt cannot promote the estimator's initial ceiling.
-The explicit `TestTransportConstrainedDormantRecovery` virtual-network trace uses real
-Pion RTP/TWCC through 6 Mbps -> 500 kbps -> 6 Mbps shaping. It measured a minimum
-budget about 497 kbps, stopped the upper output, and restored sustained 1.20 Mbps
-after release. Recovery occurred about 4.4 seconds after release in the recorded
-run; this is a bounded local trace, not a success-rate or game-latency claim.
+#### Send Queue Recovery
+
+A bounded writer-stall control exposes a limitation in the former SFU queue:
+after the estimate falls from 500 to 8 kbps, only three of 32 already-encoded
+packets drain in two seconds. The upstream unthrottled writer drains the same
+input once the writer resumes. A separate real Windows VP8 codec and
+Native forwarding control reproduces stale decoded pictures after roughly one
+second of ordinary UDP loss; the packet-count cap alone does not prevent this.
+The [ordinary media controls](./nat-traversal.md#recovery-ownership-and-forwarding)
+retain the decoded-picture and address-change evidence boundary.
+
+Congestion callbacks could also promote the estimator's unmeasured 100 Mbps
+initial capacity while a probe was pending. Congestion now only reduces the
+committed budget; a valid completed non-congesting probe owns increases.
+Publication allocation and encoder input use that same committed video capacity.
+This follows the
+[upstream distinction between congestion events and completed probes](https://github.com/livekit/livekit/blob/v1.13.6/pkg/sfu/streamallocator/streamallocator.go),
+without introducing another estimator.
+
+Two upstream measurement defects also affect completed probes. The pinned
+[finalizer](https://github.com/livekit/livekit/blob/v1.13.6/pkg/sfu/bwe/sendsidebwe/congestion_detector.go)
+returns retained capacity, initially 100 Mbps, rather than the current probe's
+acknowledged rate; no feedback can also be classified as non-congesting. The
+[probe packet group](https://github.com/livekit/livekit/blob/v1.13.6/pkg/sfu/bwe/sendsidebwe/probe_packet_group.go)
+includes the transport's preceding idle interval in the new measurement window.
+An 800 kbps probe after five idle seconds therefore either promotes 100 Mbps or,
+with only the return-value correction, fails to restore the existing budget.
+The scoped repair returns only current probe evidence, marks invalid or
+unacknowledged probes inconclusive, and starts timing at the first acknowledged
+probe packet. Removing either correction fails the real-TWCC regression. These
+changes preserve upstream estimation, loss handling and probe scheduling.
+
+Real decode also exposed a packet-format mismatch at the shared writer:
+LiveKit 1.13.6 includes padding in its probe payload, while Pion SRTP serializes
+another block from `Header.PaddingSize`. An actual library probe therefore
+arrived with 255 bytes of apparent video payload plus 255 bytes of padding.
+The adapter now removes the already-included block only at the Pion write
+boundary; pacing and TWCC retain the intended packet size. Both publication
+and single-child forwarding use that boundary. The regression checks decoded
+RTP payload emptiness after SRTP delivery, rather than treating any received
+packet as valid video. The primary owners are
+[LiveKit probe construction](https://github.com/livekit/livekit/blob/v1.13.6/pkg/sfu/downtrack.go)
+and [Pion packet serialization](https://github.com/pion/rtp/blob/v1.10.5/packet.go).
+
+The explicit `TestTransportConstrainedDormantRecovery` virtual-network trace uses
+real Pion RTP/TWCC through 6 Mbps -> 500 kbps -> 6 Mbps shaping. The repaired
+library restores sustained 1.20 Mbps video after release, with a budget assertion
+that rejects the unmeasured initial capacity. Earlier passing recovery traces
+could promote 100 Mbps and do not establish correct budgeting. The SDK
+latency-aware queue requires an extra adapter and is not retained. Earlier
+flaky comparisons also registered TWCC twice on the receiver, duplicating
+feedback; those results do not rank the queues. Product receivers already use
+the single default interceptor owner.
+
+Live audio changes exposed a related scope mismatch: Piik's TWCC tracks only
+video, but the adapter deducted audio from that measured capacity and included
+audio in expected probe usage. After restoring 320 kbps Opus, this could remove
+an already recovered video layer on a healthy 6 Mbps link. The adapter now uses
+the measured video capacity directly, matching the upstream allocator's
+video-only usage accounting. Native and SFU share this rule; quality reports
+compare video capacity with video payload while retaining total traffic in
+diagnostics. Opus rate and transport behavior are unchanged. The retained
+network gate exercises video-only and real Opus 320/96/320 kbps cases, requiring
+sustained high video and decoded audio after recovery. The former subtraction
+fails a matched trace but can be masked by later probe traffic; a passing
+recovery alone does not establish correct accounting. This is not proof of
+perceptual audio quality or behavior on every physical network.
+
+No reset, retry owner or minimum quality is added. These are bounded local traces,
+not a field diagnosis, success-rate or game-latency claim.
Ordinary focused checks cover NACK repair, no-RR probe admission, zero-write
termination and exact shutdown. Before runtime cutover, complete actual codec,
Browser and physical recovery checks rather than inferring them from this trace.
@@ -415,33 +492,21 @@ selectable only through real codec output and the normal library allocation.
Configured ceilings are conservative and do not replace bandwidth probing;
no cache of historical layer rates or additional polling owner is introduced.
-### Capture Contract For The Next Implementation
-
-Use the existing LiveKit 2.22.1 screen-share construction as the initial output
-set: original plus half-size at the same FPS, quarter bitrate with its 150 kbps
-floor. Align derived NV12 dimensions down to even values. The group supports up
-to three outputs, but this step does not invent a third product preset.
-
-The private capture command/probe contract advances to v5. Video arguments retain
-the source profile and append one `--output WIDTH HEIGHT FPS BITRATE` per output,
-ordered low to high. All current producers/readers change together, without v4
-fallback. Output profiles are codec bounds, not user preset choices.
-
-SMED v2 has a 32-byte big-endian header: magic[0:4], version[4], kind[5], flags[6],
-layer[7], timestamp100ns[8:16], duration100ns[16:24], width[24:26], height[26:28],
-payload length[28:32]. Video carries its output dimensions. PCM/status dimensions
-and layer are zero. Kind 5 is a zero-payload input-frame begin with dimensions/
-layer/flags zero and a valid source timestamp/duration; it precedes all outputs
-for that input even when the active prefix is zero. Kind 6 reports an unavailable
-output layer with a bounded UTF-8 diagnostic, zero timing/dimensions/flags.
-
-Both pipe directions use this envelope. Kind 7 carries one bounded ASCII control
-command (at most 64 bytes), with all other header fields zero: `Q`, `K layer`
-(`-1` means all outputs), `A activeCount`, `B layer bitrate`. Reuse the existing
-command grammar instead of mixing a line reader with binary video on stdin. Keyframe
-requests consumed at encode start are restored on failure; requests arriving
-during encoding must not be erased when an older keyframe completes. Control
-wakeup is I/O work, not another media-quality scheduler.
+### Capture Boundary
+
+Output construction follows the [media-quality owner](../standards/media-quality.md):
+original plus a half-size output at the same FPS, with lower bitrate bounded by
+the source ceiling. Derived NV12 dimensions round down to even values. The
+group's bounded output slots do not add product presets.
+
+The current [capture reader](../../internal/app/nativecapture/protocol.go) owns
+frame layout, kind-specific validation and allocation limits; [status](../status.md)
+owns the matched bundle's protocol versions. Both pipe directions use that framed
+boundary. Input-frame events precede per-output results, including when no output
+is active; a lower-output failure remains distinct from loss of the source.
+Keyframe requests consumed at encode start are restored on failure; requests
+arriving during encoding must survive an older keyframe completing. Control wakeup
+is I/O work, not another media-quality scheduler.
Every slot owns its encoder/converter. Windows must retain one safe copy of the
WGC input surface shared by slot workers, not reuse a returned frame-pool texture.
diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md
index 0278558be..f987f8aa3 100644
--- a/docs/research/realtime-quality-adaptation.md
+++ b/docs/research/realtime-quality-adaptation.md
@@ -175,11 +175,13 @@ 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
+Upstream status checked on 2026-10-14: 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)).
+The [AMD override](https://raw.githubusercontent.com/chromium/chromium/main/media/gpu/windows/mf_video_encoder_switches.cc)
+also remains disabled by default in main and Chrome 154.0.8037.98.
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.
@@ -187,6 +189,15 @@ Windows native Auto likewise measures through its live pipeline, including the
untrusted rate controller and frame dropper. Matching native AMD physical
evidence remains pending.
+Chromium's separate [2026-10-06 MFVEA repair](https://chromium.googlesource.com/chromium/src/+/b12ee1f641ce7de36d6c5c97b1b3ee5d3f6090c9)
+recreates software rate control when the encoded dimensions change; its stated
+trigger is a VP9 rate controller failing when resolution increases. It neither
+removes the AMD H264 workaround above nor establishes restored cadence on the
+affected devices. Browser GPU preference can change the chosen hardware path,
+but integrated versus discrete GPU is not a reliable encoder-quality rule.
+Native selection already tries available adapter/encoder candidates; changing
+Chrome's preference does not select the App's capture device.
+
Later exact Browser cohorts demonstrated why runtime evidence is useful:
| Path | Actual encoder | Source / encoded / decoded | Result |
@@ -354,9 +365,9 @@ settings remained at 30 fps; the server process, kernel queues, and network
device reported no contemporaneous resource or drop pressure. The deployed
LiveKit Server 1.13.5 also predates the upstream simulcast RTX pairing repair:
under the affected Pion integration, repair streams were not paired and
-retransmissions were silently dropped. LiveKit Server 1.13.6 is therefore the
-accepted patch baseline. This repairs loss recovery; it does not claim to
-prevent loss on the Host-to-SFU UDP path.
+retransmissions were silently dropped. LiveKit Server 1.13.6 established the
+RTX-repair baseline; `go.mod` owns the current dependency pin. This repairs loss
+recovery; it does not claim to prevent loss on the Host-to-SFU UDP path.
The same later room also contained a separate interval in which the native
bandwidth estimate stayed near 13 Mbps while the sender media-source cadence
diff --git a/docs/research/room-interactions.md b/docs/research/room-interactions.md
index a639269d8..4e03c6ff5 100644
--- a/docs/research/room-interactions.md
+++ b/docs/research/room-interactions.md
@@ -60,9 +60,15 @@ ends that service; merely leaving the Host page does not.
session.
- The viewer discovers this optional capability independently of first-picture
startup. Late capability discovery enables data on the existing connection.
-- A strict payload union admits plain chat text or a registered reaction. Chat is
+- A strict command payload union admits plain chat text or a registered reaction. Chat is
at most 280 Unicode code points, normalized to NFC, without control/bidi
characters. It renders as text, with no markup, links, attachments or commands.
+- Received reaction names are bounded lowercase ASCII identifiers, not arbitrary
+ HTML, assets or commands. The page ignores an unsupported effect after protocol
+ validation; malformed events still fail validation. This follows the command/
+ response distinction in [Azure's extensible-enum guidance](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#enums--sdks-client-libraries),
+ checked on 2026-10-07. Existing strict pages still reject new names, so this
+ reader change alone does not authorize sending new reactions to those pages.
- The server accepts at most one interaction per 800 ms per connection. It
validates identity, subscription and target before fan-out. A slow recipient's
data is skipped at 16 KiB queued output, reserving the existing bounded queue
@@ -230,6 +236,29 @@ Bidirectional room voice is not part of the product plan.
Text, reactions and danmaku introduce no microphone capture, voice
admission or additional media peers.
+## Detached Chat Window
+
+[Document Picture-in-Picture](https://developer.chrome.com/docs/web-platform/document-picture-in-picture)
+places the current chat panel in an always-on-top window without installing a
+PWA or implementing an App window. The candidate adopts one stable React portal:
+the same session, DOM, draft, reading position and controls remain in use.
+Visibility, focus, tooltip placement and dismissal follow the displaying document.
+Theme and language follow the opener. The browser owns window movement and sizing;
+closing the window returns the panel to the page. Room retirement disposes it,
+including a window request that completes after retirement.
+
+The [specification](https://wicg.github.io/document-picture-in-picture/) requires
+a secure context and user activation; closing or navigating the opener closes
+the PiP window. It is useful for reading chat beside a windowed or borderless game;
+placement above exclusive fullscreen is not guaranteed. Transparent desktop
+backgrounds and mouse passthrough remain an
+[upstream request](https://github.com/WICG/document-picture-in-picture/issues/99),
+so CSS opacity is not offered as a misleading game-transparency control.
+[Browser support](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture#browser_compatibility)
+is incomplete, so unsupported pages retain the in-page panel. Video PiP and chat
+PiP may replace each other under the browser's single-window policy; no media
+or room owner changes. This needs no new room protocol or major version.
+
## Primary References And Rationale
- [LiveKit media and data](https://docs.livekit.io/frontends/build/media-data/)
diff --git a/docs/research/server-consolidation.md b/docs/research/server-consolidation.md
index 54c1c855a..9a76c68e1 100644
--- a/docs/research/server-consolidation.md
+++ b/docs/research/server-consolidation.md
@@ -1,8 +1,9 @@
# Server Consolidation
-- Reviewed: 2026-09-06
-- Status: implemented in the candidate; the acceptance list below is the
- remaining boundary
+- Reviewed: 2026-10-07
+- Status: published; current physical limits are in
+ [verification status](../verification-status.md). The before/after measurements
+ below describe the original 2026-09-06 consolidation, not current package cost.
- Scope: one shared Go backend for Hosted and self-contained App operation
## Current Ownership
@@ -13,9 +14,10 @@ reachability, and lifecycle policy. Within that scope `protocol` owns wire types
strict decoding, and shared scalars; `config` owns environment validation plus
the Hosted and Local compositions; `room` owns room authority, participants, and
the SQLite database; `route` decides graph transitions without I/O; `signal`
-executes signaling and SFU effects under one lock; `sfu` owns admission, LiveKit
-room control, and token issue; `app` owns HTTP composition, site access, static
-assets, and lifecycle; and `webassets` carries the embedded Browser bundle.
+executes signaling and SFU effects under one lock; `sfu` owns admission and
+embedded media through the shared Pion/LiveKit adapter; `app` owns HTTP
+composition, site access, static assets, and lifecycle; and `webassets` carries
+the embedded Browser bundle.
`internal/app` keeps the native, launcher, and platform boundaries and
supervises no server process. These are useful boundaries, not duplication to
remove.
@@ -39,7 +41,8 @@ requires a concrete ownership gain before further extraction.
The graph controller is now universal. The former rollout switch and ordinary
Host-star signaling path were removed together, so Hosted and Local
-share one route authority and LiveKit remains an optional fallback of that graph.
+share one route authority. Embedded SFU remains an optional fallback of that graph
+under [ADR-0013](../adr/0013-embedded-node-local-media.md).
## Target Composition
@@ -59,8 +62,8 @@ own their defaults, reachability and lifecycle policy.
Each room keeps one serialized state owner. Go HTTP/WebSocket handlers must not
mutate room, share and route maps independently. Run password derivation and
-LiveKit I/O outside that owner, then apply results only while the exact session,
-share and operation identities still match. Preserve the existing distinction
+media-transport I/O outside that owner, then apply results only while the exact
+session, share and operation identities still match. Preserve the existing distinction
between decisions and effects rather than replacing Node serialization with
unrelated locks around each map.
@@ -70,10 +73,9 @@ unrelated locks around each map.
[`embed`](https://pkg.go.dev/embed) serve HTTP and the built assets.
- Signaling reuses the App's
[`coder/websocket`](https://pkg.go.dev/github.com/coder/websocket).
-- LiveKit room control and tokens use the standard library only: HS256 JWTs and
- Twirp JSON requests. The official
- [LiveKit Go SDK](https://github.com/livekit/server-sdk-go) adds a large module
- and license surface for a handful of call shapes and is not used.
+- Embedded SFU and Native media share `internal/media/forwarding`, which reuses
+ Pion transport and LiveKit media components. SDP/ICE uses the authenticated room
+ WebSocket; no external LiveKit room API or media-token service remains.
- Room persistence keeps the current schema and transactions on the pure-Go
[`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) driver, which
builds without cgo and holds one exclusive connection; no ORM is needed.
@@ -99,6 +101,31 @@ and [ADR-0010](../adr/0010-cross-platform-client-runtime.md) as amended by
## Measured Before And After
+### Signaling Execution Check
+
+On 2026-10-07, an isolated Windows loopback check used 10 rooms with one Host and
+20 Viewers each. Nine Hosts changed their display names 10 times/s, exercising
+presence encoding and fan-out; another task changed room policy 10 times/s,
+and four password tasks ran once/s outside the signaling lock. The diagnostic
+variant additionally wrote 200 synthetic route records/s through the real file
+sink under that lock. Each run measured 200 requests from a separate room.
+
+| Storage / diagnostics | Signaling lock wait P95 | Request round-trip P95 | Retained connections |
+| --- | ---: | ---: | ---: |
+| Memory | Below clock resolution | 6.5 ms | 210/210 |
+| SQLite | 1.0 ms | 5.9 ms | 210/210 |
+| SQLite + diagnostic sink | 3.0 ms | 7.0 ms | 210/210 |
+
+These short local checks do not establish WAN, slow-disk or maximum-capacity
+performance. A separate overload run at 900 name changes/s and 40 password
+tasks/s reached 205 ms request P95 and lost 12 connections while bounded queues
+were exercised; it is not a passing workload. The ordinary workload provides
+no reason to introduce room actors or another mutation owner. Preserve the
+bounded queues and unlocked password/media work; measure synchronous storage
+and diagnostic sinks before changing execution for a larger deployment.
+
+### Original Package Consolidation
+
One machine, 2026-09-06: Windows 11, Node 24.15.0, Go 1.26.6. A script spawned
each server with the same production configuration (`PORT=18787`, HTTPS public
origin, site-access password, one STUN URL, loopback listen host), polled
diff --git a/docs/standards/configuration.md b/docs/standards/configuration.md
index 040ea29b6..5f657105f 100644
--- a/docs/standards/configuration.md
+++ b/docs/standards/configuration.md
@@ -65,7 +65,7 @@ Automatic SFU fallback runs inside the Hosted process when `SFU_UDP_PORT` is set
| --- | --- |
| `SFU_UDP_PORT` | Optional UDP media port `1..65535`; unset or blank disables SFU and fixes Privacy mode on for every room. Set `7882` for the standard public listener and allow the Host to choose Privacy mode. |
| `SFU_LISTEN_HOST` | IPv4 bind address, default `0.0.0.0`; independent of HTTP `LISTEN_HOST`. Read only when SFU is enabled. |
-| `SFU_PUBLIC_IP` | Optional explicit IPv4 advertised-address override for a host behind NAT. Read only when SFU is enabled. |
+| `SFU_PUBLIC_IP` | Optional explicit IPv4 advertised-address override for a host behind NAT. It changes the advertised address, not the port: forward the same UDP port as `SFU_UDP_PORT`. Read only when SFU is enabled. |
| `SFU_ONLY` | `false` by default. `true` requires `SFU_UDP_PORT` and sends every room through SFU, without Peer candidates or background P2P convergence. Applies after Server restart. |
SFU control uses the application's authenticated signaling connection. No
@@ -108,30 +108,32 @@ schema field. The user settings are:
| Setting | Contract |
| --- | --- |
-| `site` | Saved Piik Site origin. The launcher or `--site` updates it. |
+| `site` | One approved Piik Site origin. Local confirmation or explicit `--site` updates it; the launcher can revoke it. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, with the same exact-match behavior as `SITE_ACCESS_PASSWORD`. It is separate from a hosted site's password. |
-Site input in the launcher and `--site` defaults to HTTPS when the scheme is
-omitted; explicit HTTP or HTTPS is preserved. The App saves the normalized origin
-and still rejects credentials, room paths, queries and fragments. It does not
-probe protocols or downgrade a failed HTTPS connection to HTTP.
+The Site's App button requests local confirmation for the page's exact origin.
+The `--site` automation input defaults to HTTPS when the scheme is omitted;
+explicit HTTP or HTTPS is preserved. Both save the normalized origin and reject
+credentials, room paths, queries and fragments. The App does not probe protocols
+or downgrade a failed HTTPS connection to HTTP.
The launcher remembers the last mode confirmed with the launch button. This
optional preference lives beside the configuration as `client.json.mode` (or
`.mode`); removing it restores the initial selection without
-changing saved settings. Older App versions ignore this file. Without a valid
-preference, a saved Site is selected when present; otherwise the public
+changing saved settings. Without a valid Local/Public preference, the public
invitation link is selected. Choosing a mode does not start it until confirmed.
Local mode remains available for the same network.
Selecting Local or Public Link, including through the CLI, preserves the saved
-Site and its allowed App origin; only an explicit Site edit replaces it.
+Site and its allowed App origin; only local approval/revocation or `--site`
+changes it. The Site can use native capabilities while the App waits in its
+launcher; it does not need a Local room service.
For Local mode, the launcher selects a sole active address or sole private IPv4
address automatically. With several choices it shows interface names and IPs;
an ambiguous choice must be selected before launch. `--lan-address` preselects
an active address. Selection applies to this launch and is revalidated at startup.
It sets the local invitation origin; HTTP still listens on the existing wildcard
-listener and ICE remains free to use available media interfaces. Public Link and
-Site mode require no local address selection.
+listener and ICE remains free to use available media interfaces. Public Link
+and use through an approved Site require no local address selection.
Command-line options select entry and local runtime behavior:
@@ -146,6 +148,10 @@ Command-line options select entry and local runtime behavior:
Share quality, room access policy, language and theme are configured in
the shared Web UI. Decorative motion follows the system's reduced-motion preference.
+App-wide picture/audio quality preferences use `.quality`; removing
+that optional file resets them on the next App launch. It contains no device or
+room credentials. The [media owner](./media-quality.md#video-profiles) defines when
+preferences apply; older Apps ignore the file and keep reading `client.json`.
## Diagnostics
@@ -161,10 +167,12 @@ Diagnostics are local and opt-in. Enable them **before** reproducing the problem
| Browser Host/Viewer | Click the **Debug** chip icon after the theme control and confirm the reload, or add `?debug=1` before any invitation fragment | The same control becomes a download arrow for the web report |
| Hosted Server | Start with `--debug`, `PIIK_DEBUG=server` or `PIIK_DEBUG=route` | On Unix, `kill -USR1 `; also exported at orderly shutdown |
-App **Debug launch** enables App and Browser collection for that run before
-starting the selected mode. It records Native capability results and subsequent
-capture, connection and local-server activity. Use `--debug` for failures before
-the mode selector opens. This choice does not change the saved App configuration.
+App **Debug launch** starts App collection immediately, including while waiting
+for a Site. Pages subsequently opened from the launcher also collect Browser
+logs; already-open Site pages use their own Debug control. It records Native
+capability results and subsequent capture, connection and local-server activity.
+Use `--debug` for failures before the launcher opens. This choice does not change
+the saved App configuration.
When bypassing the mode selector, `--debug` and `PIIK_DEBUG=client` enable backend
collection; enable the Browser control separately for browser-side diagnosis.
@@ -271,12 +279,11 @@ When `NAT_PREDICTION_ENABLED=true`, the server derives
`stun::3479` and `:3480` from the first ordinary STUN authority
on UDP 3478. The Host sees a pre-share switch that defaults on and may disable
it. The capability adds no media route or third-party service. It needs both
-cloud security-group rules and the host's `/etc/nftables.conf` rule. Piik
+cloud security-group rules and the host firewall. Piik
binds every required UDP listener before opening room persistence or accepting
signaling; a bind failure rolls back all newly owned sockets. Close and End
-retire these listeners with the application. Existing coturn listeners must
-be retired in the coordinated deployment because two processes cannot own the
-same ports. Opening a cloud port without a listener has no effect, and local
+retire these listeners with the application. No other service may own the same
+listening ports. Opening a cloud port without a listener has no effect, and local
binding alone does not prove external reachability. Disable the capability
before removing firewall rules. Same-IP ports
expose destination-port allocation behavior; a full RFC 5780 alternate-address
diff --git a/docs/standards/engineering.md b/docs/standards/engineering.md
index 048a4bc80..5c8341939 100644
--- a/docs/standards/engineering.md
+++ b/docs/standards/engineering.md
@@ -79,14 +79,33 @@ the relevant existing fixture/check together. Do not create another handwritten
field list in documentation, parallel DTO hierarchy or schema generator without
a demonstrated reduction in ownership or drift.
-Keep UI presets, requested values, runtime capabilities and hard resource limits
-distinct. A current menu is not sufficient reason to freeze its values into every
-wire reader or platform adapter. Preserve meaningful authorization and resource
-bounds; prove that a producer's valid aggregate output fits its consumer, including
-serialization overhead. A per-item limit alone does not establish that contract.
-
-When extending a boundary, exercise a representative new value and an unchanged
-consumer before calling the design extensible. For media, include another source
+Keep UI presets, requested values, runtime capabilities, observations and hard
+resource limits distinct. Every bound needs an owner and a reason: wire/codec
+representation, accepted product policy or resource protection. A current menu
+or retry budget is not sufficient reason to freeze its values into a wire reader.
+Validate representation at the interface, then supported operations at their
+owner before allocating or changing live state. An unsupported setting should
+use the existing operation rejection, without retiring an otherwise valid session.
+
+A capability description may exceed the reader's implementation. Intersect it
+with local support and budgets; do not treat a stronger peer as absent or grant
+resources from its declaration alone. Measurements are observations, not resource
+requests: their range need not match a setting or estimated network budget.
+An unusable optional metric should become unavailable without discarding valid
+evidence or closing media control. Identity, generation, authority and required
+state consistency remain strict; never clamp a measurement into an invented fact.
+
+Detect optional platform features through bounded capability checks and actual
+operation results, not OS/browser version allowlists. Keep API availability,
+permission and successful execution distinct; version data is diagnostic context.
+
+Preserve meaningful authorization and resource bounds; prove that a producer's
+valid aggregate output fits its consumer, including serialization overhead.
+A per-item limit alone does not establish that contract.
+
+When extending a boundary, exercise a representative new value, a larger peer
+capability and an unchanged consumer before calling the design extensible.
+Check both acceptance and the effect of rejection. For media, include another source
orientation and distinguish encode, decode and forwarding support. Optional
presentation additions need a bounded unsupported-value policy separate from
authority violations, following [versioning](./versioning.md#extending-interfaces).
@@ -171,6 +190,8 @@ above rather than searching the tree.
Construction is part of acquisition: if it throws, release resources created
before the failure and report through the existing operation owner. A failed
allocation does not erase the operation's authoritative identity.
+ Initialize callback-visible state before registration or binding; library
+ constructors can start background work before returning.
Ordered shutdown may require a ready auxiliary transport to outlive the stop
request; its owner closes it after dependants retire. External convenience
actions must not block lifecycle handling, and repeated attempts stay bounded.
diff --git a/docs/standards/media-quality.md b/docs/standards/media-quality.md
index 999f1e500..29d40a239 100644
--- a/docs/standards/media-quality.md
+++ b/docs/standards/media-quality.md
@@ -18,7 +18,9 @@ contract. [Status](../status.md) indexes delivery and remaining acceptance.
latter uses one supported native codec path and never infers a target from
a title. An ordinary Web Host does not probe localhost.
- Camera is a peer source choice in the same picker, including on phones. Only
- the chosen source requests permission. Browser/camera Host microphone capture
+ the chosen source requests permission. Camera input and thumbnails request
+ an uncropped device format; sender constraints own later scaling.
+ Browser/camera Host microphone capture
is explicit and defaults to Browser voice processing, separately from screen audio.
The Host may turn that processing off for music or virtual audio inputs;
the input change preserves the existing mixed output and leaves the previous
@@ -42,6 +44,12 @@ contract. [Status](../status.md) indexes delivery and remaining acceptance.
frame matches its desktop source dimensions. Other frames retain their own
aspect ratio. This changes only the encoded presentation, never the game or
display settings; vendor-private scaling is not inferred.
+- Windows and Linux outputs preserve source presentation aspect within the
+ selected ceiling, rounded to even dimensions. Small sources are not enlarged
+ to a fixed landscape canvas. Windows uses its existing converter; Linux uses
+ GStreamer's size negotiation. Source resizing does not create a new room or
+ route, and frame envelopes carry actual output dimensions. Network adaptation
+ remains owned by the existing media framework.
- Windows SDR conversion owns one output color contract: limited-range BT.601
NV12 with matching H.264 metadata. RGB capture and decoded video retain their
input range/matrix through conversion and relay scaling; dimensions do not
@@ -75,7 +83,9 @@ contract. [Status](../status.md) indexes delivery and remaining acceptance.
not intentionally stop capture, encoding, or upload.
- Live quality changes update the current capture and Host sender-track
constraints plus sender ceilings without reopening source selection or
- replacing a healthy route.
+ replacing a healthy route. Shared Browser capture constraints express size
+ ceilings, not ideal dimensions or a fixed aspect ratio: lowering a ceiling
+ must not request a crop, and source orientation remains Browser-owned.
- Native quality changes use the same room settings. The App prepares a new
platform capture/encoder generation, then replaces the old generation behind
the same encoded source and PeerConnections; audio-only changes update the
@@ -101,6 +111,17 @@ contract. [Status](../status.md) indexes delivery and remaining acceptance.
## Video Profiles
+The Host remembers explicitly applied picture and source-audio quality settings.
+Browser-only use stores them per origin. An App-opened page uses the running,
+authorized App's optional preference store when available, including across
+temporary public-link origins; older or unavailable Apps retain Browser storage.
+These preferences seed a new page, never an active share. Explicit edits and
+authoritative room recovery win over a late preference read. Failed or superseded
+changes and server snapshots do not replace the preference; pending saves retain
+only the latest applied choice. Storage failure never fails capture or startup.
+This does not retain capture permission, device/session identity, routing policy
+or microphone activation, and reading settings claims no Native media session.
+
The three recommended profiles are ceilings, not delivery guarantees:
| Profile | Target | Video bitrate ceiling |
@@ -109,13 +130,34 @@ The three recommended profiles are ceilings, not delivery guarantees:
| `1080p30` | 1920x1080 at 30 fps | 5 Mbps |
| `1080p60` | 1920x1080 at 60 fps | 8 Mbps |
-`1080p30` is the default. Advanced resolution includes 480p, 720p, 1080p, and
-1440p; frame rate, bitrate, and `maintain-resolution | balanced |
-maintain-framerate` remain independent controls. Display video uses the standard
+`1080p30` is the default. The 2.0 contract carries numeric width/height ceilings;
+480p, 720p, 1080p, 1440p and 4K are UI shortcuts. Advanced picture settings also
+accept custom even dimensions within the combined implementation bounds below.
+Width and height form one applied edit. A size change caps the requested frame
+rate in the same quality operation when the larger size requires a lower limit.
+Larger wire-valid requests do not
+authorize an adapter to exceed its local capture, codec or allocation limits.
+Numeric requests use representational bounds rather than the current control
+ranges. Current capture admits integer 1..240 fps and 0.1..240 Mbps, further
+limited by picture size. Advanced controls accept typed values alongside sliders;
+the three recommended profiles and default remain unchanged. These are useful
+product controls, not universal codec
+limits or a minimum for framework-driven output. Codec and resource budgets
+apply separately. App descriptions may advertise stronger capabilities;
+controls use their intersection with local support. An unsupported request is
+an operation failure, not malformed signaling. An unsupported saved preference
+does not seed capture or overwrite its stored value with a fallback.
+Derived outputs must stay within their source bitrate ceiling, including when
+that ceiling is below a usual lower-output floor. Codec preflight must observe
+real encoded progress; cadence tolerance alone cannot make zero output pass.
+Frame rate, bitrate, and `maintain-resolution | balanced |
+maintain-framerate` retain their controls. Display video uses the standard
`contentHint = "motion"` for game motion.
Native H264 retains Constrained Baseline. Capture validates the coding-tool subset under
RFC 6184, including equivalent Constrained Baseline constraint bytes, and reports
the actual SPS profile/level; one vendor's byte spelling is not the contract.
+The platform encoder selects its level from the requested format. Validate the
+actual supported level instead of forcing a calculated minimum into the encoder.
Native ingress and relay accept only a negotiated profile/packetization compatible
with their unchanged encoded downstream source. A codec name alone is not enough;
same-source renegotiation must preserve that boundary before altering the live peer.
@@ -124,6 +166,29 @@ derivation, validate every H.264 sequence parameter set's supported format,
coded storage and visible crop; a later small descriptor cannot authorize earlier
large input.
+Candidate capture admits even dimensions up to 8192 per axis and 36,864
+16x16 macroblocks per picture, including alignment. Its shared codec budget
+follows H.264 Level 5.2: 2,073,600 macroblocks per second and a 240 Mbps video
+bitrate ceiling; capture scheduling is separately bounded at 240 fps.
+This admits 1080p240, 1440p144, 5120x1440 at 60 fps and landscape/portrait
+4K60. It does not admit a full 8K picture. Decoder alignment has a
+separate coded-storage bound; reference-picture storage is bounded before
+Native derivation. These are admission limits, not a device performance promise.
+App discovery reports size, pixel-count, frame-rate and bitrate limits through the
+existing native health description. Controls use the intersection with local
+implementation limits; a remote claim cannot raise those limits. Whole-route
+and physical-device acceptance remain separate from available controls.
+Use platform decoder defaults for internal provisioning, rather than turning
+independent axis ceilings into a maximum-square preallocation. Encoded input
+and actual output storage remain bounded before use.
+
+Quality observations have independent numeric bounds. A valid capacity estimate
+above the requested send rate remains evidence; invalid optional measurements
+become unavailable. Invalid Native quality samples are discarded without closing
+media control. Required identity, generation and state evidence must still pass
+before a sample can influence route decisions. SFU encoding descriptions likewise
+do not grant resources: the SFU admits supported formats before allocating them.
+
Framework-driven downscaling under bandwidth or device pressure is valid within
the selected degradation preference. A low decoded resolution alone is not a
defect and does not justify a new quality floor. Review avoidable disruption,
@@ -154,6 +219,10 @@ its resources even when a Browser operation remains pending. The result stays
fixed for the current share and is reconsidered for a new share.
ADR-0007 owns probe and negotiation mechanics.
+Codec selection preserves the negotiated repair stream associated with the
+selected primary codec. Native and SFU receivers use standard Pion RTX/NACK;
+codec restrictions must not strip recovery or disable SRTP replay protection.
+
The Browser chooses the concrete encoder implementation. A reported H.264 codec
does not by itself prove hardware acceleration, and Web content cannot select a
specific MFT, NVENC, AMF, or QSV implementation.
@@ -171,8 +240,10 @@ 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.
+is tried within the four-second budget. A measured frame deficit beyond that
+allowance ends the probe immediately: remaining frames cannot change its result.
+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.
@@ -239,7 +310,9 @@ bitrate still follows the Host ceiling; ordinary fallback restores its picture
sender limits. Preparation and applied-budget completion re-evaluate pending
membership immediately; accepting a recovery frame still commits the handoff.
The first eligible consumer follows the same path; unsupported APIs or failed
-pooling use ordinary senders. Prepared ordinary quality candidates, Native
+pooling use ordinary senders. Negotiated codec incompatibility retires only
+that connection's pool membership; healthy siblings keep their producers.
+Prepared ordinary quality candidates, Native
ingress and Browser SFU remain independent compositions.
Each ordinary Browser PeerConnection owns stock WebRTC congestion control and
@@ -325,10 +398,33 @@ application-defined whole-room lowest-common-denominator target.
## Screen Audio
-Display audio uses `contentHint = "music"`. The live sender ceilings are 64,
-128, and 192 kbps with 128 kbps default. Browser and Native Peer answers request
-Opus stereo and a 192 kbps receive ceiling, including renegotiation. These are
-local receive preferences, independent of the offer's receive preferences.
+Piik supports mono and stereo Opus content through the existing audio
+track. Mono inputs remain valid; Browser and Native mixing use a stereo output.
+A stereo receive preference or two-channel declaration does not prove distinct
+source channels. [Fidelity evidence](../research/media-fidelity.md#stereo) owns
+decoded-channel checks. Surround delivery is outside the current scope; it needs
+capture, channel-layout, mixing, codec and playback support, not only a channel
+count setting.
+
+Display audio uses `contentHint = "music"`. 2.0 keeps 64/128/192 kbps
+shortcuts and the 128 kbps default, with a custom numeric ceiling in the same
+sound settings. Current Browser/native senders support 6..320 kbps; the numeric
+wire format uses Opus's 6..510 kbps bounds independently of those shortcuts.
+The native 20 ms encoder also bounds each Opus payload to 1152 bytes, leaving
+48 bytes for RTP/extensions/SRTP within a 1200-byte datagram. An average bitrate
+ceiling alone does not bound VBR peaks; Opus RTP keeps each encoded frame intact.
+A request above the current sender's bitrate ceiling fails that operation
+without retiring native control.
+An audio-only update retains capture, media connections and video startup state.
+
+Browser, Native and SFU receive answers request Opus stereo and the full Opus
+receive range, including renegotiation. Browser senders enforce a smaller
+negotiated receive limit on their own encoding, falling back to libwebrtc's
+mode-dependent default when no valid limit is declared. Native/SFU unchanged forwarding
+requires a receiver that can accept the shared source's full range; a smaller
+or undeclared limit rejects that edge before binding, without reducing the source or
+its healthy siblings. An omitted preference does not mean unrestricted reception.
+These receive preferences are independent of the offer's receive preferences.
The SFU publication uses stereo, DTX disabled, and RED
disabled; this accepts less burst-loss resilience in exchange for bounded
publisher traffic. The Browser and source still decide whether an audio track
diff --git a/docs/standards/presentation-lifecycle.md b/docs/standards/presentation-lifecycle.md
index 519136346..e57f0790a 100644
--- a/docs/standards/presentation-lifecycle.md
+++ b/docs/standards/presentation-lifecycle.md
@@ -28,15 +28,37 @@ A capable, opted-in authenticated room session
outlives its current publication. Stopping capture retires media, not chat;
restoring an existing room joins it without starting capture. Entering the page
alone does not create a room. Room close, lost authority and page teardown still
-retire the session. Connected Viewers may keep interacting while the Host page
+retire the session, including its App control connection and pending discovery.
+That retirement cannot wait for the denied page to unmount. Connected Viewers may keep interacting while the Host page
is absent, provided the room service and their admitted sessions remain available.
[Rooms and access](./rooms-access.md#room-interactions) owns interaction authority
and retention; [status](../status.md#accepted-interaction-phase) owns release readiness.
-Piik App opens this same application in the system Browser. Its small
-startup surface selects Local, temporary public invitation, or a configured
-Site before entering the Host workspace. Viewer links opened at that activated
-origin may use the same running App without changing the Viewer UI. Starting a share offers the
+On capable desktop browsers, chat can move to a Document Picture-in-Picture
+window. It remains one presentation of the admitted room session, with the same
+draft and history. Read visibility and guidance follow its current document.
+Closing the window returns chat to the page; room retirement or opener teardown
+closes it, including a late asynchronous open. Browser window support does not
+promise game-overlay transparency, click-through or exclusive-fullscreen coverage.
+
+Piik App opens this same application in the system Browser. Its startup surface
+offers Local and temporary public invitation; the running App is already
+available to its approved Site before either mode starts. A Site's explicit App
+action discovers the local approval page, where the user confirms its exact
+origin. The launcher shows and revokes that approval. Activating a page supplies
+native capability without restarting its room or healthy media session.
+The source selector exposes that same connection action before sharing; a
+successful connection refreshes its current list. An already approved exact
+origin needs no repeat confirmation, but the page still verifies authorized App
+health. Closing the selector cancels its pending connection action. Existing
+Browser shares retain their capture implementation until sharing ends.
+App-created Local/public-invite Host pages open at their automatically approved
+loopback origin. The shared App button reflects verified authorized health,
+including activation through another entry and return to an activated page;
+a remembered activation alone cannot show readiness. Idle verification holds
+no media session or polling loop.
+Viewer links opened at that activated origin may use the same running App
+without changing the Viewer UI. Starting a share offers the
Browser picker and any exact App-owned native windows. It never chooses a
window automatically. A reproduced Browser-window, capture, or background
failure is required before introducing an embedded Web runtime.
@@ -62,8 +84,9 @@ blocks Browser selection or overrides an explicitly chosen tab. Refresh remains
reachable after failed App discovery; pending attempts are shared, completed failures
do not suppress later discovery. Cancelling the selector or ending a share
releases its unused control session; an obsolete operation cannot install or
-retire another operation's media. Camera thumbnails own only temporary preview
-inputs; leaving their tab or picker retires them. An active shared camera is
+retire another operation's media. Cancellation covers discovery and the opening
+handshake, not just a connection returned later. Camera thumbnails own only
+temporary preview inputs; leaving their tab or picker retires them. An active shared camera is
borrowed, never reopened or stopped by preview. Optional native ingress uses an already
available connection without another scan. If it is not ready at Browser
startup, that share keeps Browser senders.
@@ -235,12 +258,26 @@ recovery state still requires a fresh current frame to clear. SFU media is
retained independently of transient room-signaling loss, but Browser or OS
suspension and page reclamation remain outside Web guarantees.
-Failure of the local Native media bridge disables Native reception for the
+The local Native media bridge owns bounded ICE recovery on its existing
+connection and stream. Its offer identity fences each answer; retirement cancels
+pending negotiation and cannot recreate a source or receiver. Connection recovery
+does not assert fresh media, and an intentionally paused source is not a failure.
+First-frame and playback owners retain their existing evidence requirements.
+
+Terminal failure of the local Native media bridge disables Native reception for the
current Viewer session before route recovery runs. Future peers in that session
use Browser reception; existing healthy peers are not torn down by this choice.
Parent-network failure alone is not evidence that the local Native capability
is unavailable.
+The Native Host's local bridge remains required by its Browser share owner:
+the stream supplies preview and Browser quality candidates and participates in
+source and quality changes. A terminal failure retires that share through the
+existing Host lifecycle; it is not merely a missing preview. An individual
+downstream network failure instead recovers that edge and does not retire the
+bridge or capture. Browser capture's optional Native fanout can fall back to
+Browser senders because the Browser still owns its source.
+
## Presence And Diagnostics
Host and Viewer pages show the authoritative online Viewer count and roster,
diff --git a/docs/standards/public-introduction.md b/docs/standards/public-introduction.md
index b05691b36..ae7a8464e 100644
--- a/docs/standards/public-introduction.md
+++ b/docs/standards/public-introduction.md
@@ -14,7 +14,7 @@ understand what Piik does, choose an entry and complete the first useful action.
| --- | --- | --- |
| README, English and Chinese | What is this, and how do I start? | Brand introduction and interface preview, features, getting started, self-hosting, contribution and license |
| `site/`, for GitHub Pages | What does using it feel like, and how do I start? | Room illustration and primary download link, illustrated steps, online/App/existing-site walkthroughs, FAQ, platform downloads, self-hosting and documentation |
-| Getting-started guides | Which button or mode do I use? | Browser invitation, sharing, App setup and three mode choices |
+| Getting-started guides | Which button or mode do I use? | Browser invitation, sharing, App setup, room creation and Site approval |
| Self-hosting guides | How do I put my own site online? | Run the binary, set the domain and HTTPS proxy, open ports and verify; service management remains a separate reference |
| Developer and operations docs | How do I build or host it? | Existing configuration, deployment, architecture and diagnostic owners |
diff --git a/docs/standards/rooms-access.md b/docs/standards/rooms-access.md
index d40a5b354..2431119fa 100644
--- a/docs/standards/rooms-access.md
+++ b/docs/standards/rooms-access.md
@@ -132,6 +132,13 @@ for participants still connected to the same room. Interaction subscriptions
inherit existing membership, replacement and revocation checks; they grant no
additional authority and do not modify media routes.
+Sending a reaction requires a registered identifier. Received reaction events
+validate their envelope, identity, target and bounded identifier, then the page
+skips effects it cannot display. An unknown effect does not terminate signaling,
+enter history, clear a draft or confirm a pending send. Unknown message kinds,
+extra fields and malformed identities remain invalid; this presentation rule
+does not make commands or access checks extensible.
+
Delivery is transient and bounded. The server keeps no message history; each
page keeps only a bounded set of received messages and effects, without writing
them to storage or diagnostics. Re-admission to the same room may retain that
diff --git a/docs/standards/routing-transport.md b/docs/standards/routing-transport.md
index c84ede8a5..7e6b50c4f 100644
--- a/docs/standards/routing-transport.md
+++ b/docs/standards/routing-transport.md
@@ -54,6 +54,11 @@ share generation:
attempts show retry 1/3, 2/3 and 3/3 on pages supporting that progress format.
Retries use the same serial controller and normal operation deadlines;
waiting Viewers and untried parents receive their opportunities first.
+ A retry may use bounded two-App socket preparation under ADR-0009 before its
+ one Pion connection. The first attempt and Browser-only transport stay ordinary;
+ failed optional preparation cannot disable the App for the session. Socket
+ ownership transfers only after mutual success; cancellation joins workers
+ before freeing their slot.
It is not a participant capability, route score, or SFU preference. Candidate
generation and selected-pair diagnostics retain only
`ordinary | predicted | unknown` provenance.
@@ -119,8 +124,13 @@ deadline or start an independent recovery loop.
When NAT traversal enhancement is enabled, another operation may use the
remaining connection-attempt budget after rollback. The waiting display uses the actual
server-issued attempt ordinal; it never counts time as an attempted connection.
-Committed Browser P2P recovery first restarts ICE on the same connection, then
-rebuilds that connection if necessary, within the existing two-request budget.
+Committed P2P recovery first requests an ICE restart, then requests a rebuild
+if necessary, within the existing two-request budget. A sender without restart
+support, including the current Native sender, replaces its connection on that
+first request.
+The budget belongs to the current parent and survives physical connection or
+Browser/Native backend replacement. Only a connected transport or a new parent
+resets it; replacing an unconnected backend cannot renew recovery indefinitely.
Capable Native receivers also renegotiate that connection while retaining the
encoded source, local playback and downstream edges when its codec/audio shape
is unchanged. Pion owns ICE restart; each actual gathering owns fresh supplemental
@@ -214,6 +224,9 @@ An active configuration describes that connection; it cannot create a replacemen
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.
+Retained media keeps its original acquisition opportunity when signaling sessions
+change. Its failure cannot consume a new session's untried opportunity; a new
+physical connection binds the current one. This applies to Peer and SFU edges.
An exhausted opportunity remains an explicit failure until an eligible new fact
reopens acquisition. Source capture remains Host-owned throughout.
@@ -264,7 +277,8 @@ design. All-UDP-blocked networks currently end in bounded failure.
App Local mode runs this graph without SFU, NAT prediction, or default STUN.
One-link mode tunnels only HTTP/WebSocket control and adds public STUN to the
same Browser/Native P2P edges. Reachable peers form one mixed relay tree; an
-unreachable path adds no route type or score. Site mode uses Site transport.
+unreachable path adds no route type or score. App-enabled Site pages use that
+Site's transport configuration.
A Native Host reserves one loopback edge for Browser preview; it consumes no
route-copy capacity or gateway mapping. Direct children and the admitted Native
diff --git a/docs/standards/versioning.md b/docs/standards/versioning.md
index d218dd5cc..7bb0bdb35 100644
--- a/docs/standards/versioning.md
+++ b/docs/standards/versioning.md
@@ -61,10 +61,19 @@ baseline contract; introduce a small optional capability only for a concrete
new feature. Do not add a version-range solver, general adapter framework,
parallel authority writers or blanket old-client fallback paths.
+Keep the 2.x baseline long-lived. Substantial new functionality can remain a
+minor release when older peers retain established behavior and new behavior is
+explicitly opted into. A small feature can still be breaking if it unconditionally
+changes a strict message, existing semantics or durable authority. Review those
+boundaries before choosing the version; neither feature count nor a desire to
+avoid a major justifies mislabeling an incompatible release.
+
## Extending Interfaces
-Command/event JSON objects still reject unknown keys and enums, and required
-fields reject omissions. Therefore even adding a field can break the other side.
+Command/event JSON objects still reject unknown keys, and required fields reject
+omissions. Most enums remain closed; the bounded display-only reaction identifier
+has an explicit [receive-side exception](./rooms-access.md#room-interactions).
+Therefore even adding a field can break the other side.
The former required `sfu` addition to strict `/api/capabilities` demonstrated
both directions of this problem; an unchanged signaling identifier does not
cover HTTP compatibility.
@@ -75,7 +84,15 @@ capability means unavailable. Commands, credentials and authority checks remain
strict. A new
message type, enum value or behavior-changing field is emitted only after the
other side advertises support; accepting unknown metadata is not permission to
-execute unknown commands. Verify both readers before calling a change additive.
+execute unknown commands. A more tolerant reader does not update already-released
+readers: do not emit a new reaction to legacy pages merely because current pages
+can skip it. Verify both readers before calling a change additive.
+
+In the 2.0 contract, numeric media descriptions and retry progress
+are independent of current implementation limits and retry policy. Extending
+those policies does not require another count-specific capability flag.
+Representation acceptance still requires local operation/resource admission;
+it is not a promise that every same-major peer can produce a new media format.
Native discovery distinguishes an incompatible Piik App from an absent App and
reports protocol numbers to local diagnostics; the source picker offers update/
@@ -176,7 +193,9 @@ after all required artifacts and checks pass. The publisher verifies matching
version, source SHA and checksums, then creates/uploads/publishes one GitHub draft.
When there are no unpublished product changes, CI validates a development build
and skips packaging and publication; it never assigns an old release version to
-a new source SHA. The separate Website workflow updates the static site after CI.
+a new source SHA. The separate Website and Gitee mirror workflows follow successful
+main CI independently. Mirror failure must remain visible without blocking GitHub
+publication or Website deployment; retries use the original publishing artifacts.
Failed draft uploads can resume; published artifacts are never overwritten.
An older draft retried after a newer release cannot take over `latest`.
@@ -225,11 +244,11 @@ 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, 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.
+sections fail PR and publishing validation. GitHub Actions shows the generated
+release text in its publishing run summary. Manual candidates validate and build
+the requested revision without requiring release copy in branch checkpoints;
+their version labels artifacts and never enables publication. The PR body remains
+the review surface until integration preserves it in the squash commit.
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
diff --git a/docs/standards/visual-language.md b/docs/standards/visual-language.md
index 18ef8d893..d3e2f0ece 100644
--- a/docs/standards/visual-language.md
+++ b/docs/standards/visual-language.md
@@ -67,6 +67,10 @@ participant, stays open after sending and keeps its position when retargeted.
One-shot settings and guidance retain their existing disclosures. Escape closes
inner guidance first, then the most recently opened floating panel, then theater
mode; an IME composition must not dismiss these surfaces.
+Detached chat uses the same content in the browser's own always-on-top frame.
+Close, return-to-tab, movement and resizing belong to that frame; do not duplicate
+its controls in the compact chat header. Keep messages and text contrast
+primary; do not simulate transparent desktop content with a washed-out panel.
Keep the application language control's segmented Chinese, English and visual
shortcuts. When additional catalogs exist, insert one language menu
@@ -156,9 +160,16 @@ the television, above the couch. Its disclosure groups quality presets and pictu
parameters with sound and microphone settings; presets remain available before
sharing starts. Separate the less frequent connection/codec options in a nested
disclosure. Expand in page flow, pushing later content down instead of covering
-the picture or participants. Use two content groups on wide screens and stack
-them on narrow screens; avoid another permanent row of settings cards. Keep
-shared presentation separate from the existing setting/resource owners.
+the picture or participants. Keep picture and sound in distinct sections; place
+related adjustments side by side where space permits and stack them on narrow
+screens. Avoid another permanent row of settings cards. Numeric quality inputs
+retain the accepted capability range; sliders offer a labelled common range for
+quick adjustment. Values outside its range or steps show a custom-value row
+instead of an inaccurate slider position. Returning to the nearest available
+common step is an explicit action; rendering, focus or reopening never changes
+the value. Derive this presentation from the current value, without a second
+setting mode. Shared presentation stays separate
+from the existing setting/resource owners.
The microphone comic depicts Host commentary gain;
the Viewer's listening-volume comic remains a different action.
@@ -311,15 +322,25 @@ not need a second mascot or decorative spinner. The header's brand mark remains
navigation identity. Keep an actionable play symbol or an exact progress value
when it helps the user proceed or understand the operation.
+Review the visible page as a composition, including open tooltips: give it one
+expressive focal illustration, rather than repeating the mascot in a heading,
+card and status. A small navigation mark may coexist with a contextual mascot;
+it is not another focal illustration. Remove decorative duplicates before
+changing the shared character or adding visibility state. This applies the
+[illustration guidance](https://atlassian.design/foundations/illustrations):
+support the task and use lighter icons or copy when another drawing adds noise.
+
| Context | Illustration and literal content | Playful caption |
| --- | --- | --- |
-| Page loading, site access check, App preparation | The shared wink mascot with the actual loading operation; no room or media-path claim | The current language's waiting pool |
+| Page loading or site access check | The shared wink mascot with the actual loading operation; no room or media-path claim | None |
+| App room startup | The shared wink mascot with the actual operation | One secondary waiting caption |
+| Confirmed App readiness | Wink mascot with literal readiness in dedicated feedback or its tooltip; an inline summary beside an existing main brand uses a status icon. Neither implies an active media route. | None |
| Source listing/start/switch, room joining, media setup/recovery, waiting for the Host | The corresponding comic with the current status; only the existing state owner can declare waiting | The same waiting pool while the wait exists |
| Error, limitation, pause, ended room or required user action | The corresponding comic and clear reason/action; settled movement keeps that verdict readable | None |
| Small controls, tooltips and inline results | Existing action/status icon and contextual guidance when needed; retain direct actions | None |
The shared `LoadingStatus`, `StageOverlay` and `WaitingCaption` apply this
-composition; source selection reuses the caption beside its existing comic.
+composition. Routine loading does not opt into playful copy automatically.
Do not infer waiting from a chosen drawing, mascot visibility or animation flag.
Healthy playback stays visible during control reconnection; this rule does not
create another overlay. An idle room awaiting a new share differs from a closed
@@ -331,20 +352,26 @@ hidden captions; viewport size does not become product state.
### Playful Copy Lifecycle
-The shared [rotation owner](../../src/client/ui/text-rotation.ts) supplies the
-clock and visibility policy; its React adapter and the static website consume
-the same rule. Locale files own the independent pools.
-
-Website/App welcome lines, waiting captions and playful title variations share
-one lifecycle. Show an entry immediately, then change it every eight seconds
-while visible. Draw without replacement until the current pool is exhausted.
-An ordinary rerender or metric update does not restart the cycle. A new semantic
-context or language starts from that context's current-language pool; never
-reuse a cross-language array index. Hidden surfaces stop advancing. On return,
-keep the current line for a full interval; reduced motion retains a static line.
-Unmount or an ended context retires timers and listeners. Empty pools omit the
-decoration; one-entry pools stay static. Real completion never waits for copy
-or an animation to finish. This cadence is a product choice, not measured progress.
+Locale files own independent pools; the shared
+[selection and rotation owner](../../src/client/ui/text-rotation.ts) and its
+React adapter provide selection, timing and visibility. Keep one secondary
+playful line in an operational page's main content, with no duplicates in
+subpanels. Review the whole composition before adding another caption.
+
+Website/App welcomes select one entry immediately and keep it during that visit.
+Tab titles retain one variation per actual activity, without a timer. Ordinary
+rerenders and metric updates do not draw another entry. A welcome's text/visual
+switch preserves its selected entry. Language changes use that language's pool,
+never a cross-language array index.
+
+Only App room startup and media waits rotate their secondary caption: show one
+immediately, then change it every fifteen visible seconds. Changes of the literal
+status within a continuous wait do not redraw the caption or reset its clock.
+Draw without replacement until the pool is exhausted. Hidden surfaces stop
+advancing; on return, keep the line for a full interval. Reduced motion retains
+a static line. Unmount or an ended wait retires timers and listeners. Empty pools
+omit the decoration; one-entry pools stay static. Real completion never waits
+for copy or animation. This cadence is a product choice, not measured progress.
Each language maintains independent contextual pools without equal counts,
paired translation keys or a fixed total. Contributors can add a locally natural
@@ -362,9 +389,9 @@ review; this copy policy does not require a mascot or a new tooltip everywhere.
| Existing surface | Copy coverage |
| --- | --- |
-| Website welcome, App launcher, sharing entry | The current language's welcome pool; the App's visual mode renders that entry's cipher. |
-| Loading, source selection and media waiting | A secondary waiting caption according to [feedback composition](#feedback-composition). |
-| Browser tab | Sharing, watching, starting, ready-to-share, not-started and waiting states may append their own variations; room identity, literal activity and warnings remain visible. |
+| Website welcome, App launcher, sharing entry | One welcome per entry; the App's visual mode renders that entry's cipher. |
+| App room startup and media waiting | One slowly rotating caption according to [feedback composition](#feedback-composition). Ordinary page loading and access checks use literal status only. |
+| Browser tab | Sharing, watching, starting, ready-to-share, not-started and waiting states may append one stable variation; room identity, literal activity and warnings remain visible. |
| Error, pause, ended state, required action, tooltip, small control | Keep the existing literal message and contextual graphic; no rotating caption is added. |
## Panel Composition
@@ -505,10 +532,13 @@ Entry screens may carry one quiet, original welcome line in Chinese or English
text mode. Pure-visual mode pairs familiar pictograms with static pixel
pseudo-lettering tied to the same line. Hide that visual cipher from assistive
technology; the ordinary text sentence remains readable. Switching between text
-and visual mode preserves the selected welcome entry; rotation follows the
+and visual mode preserves the selected welcome entry; selection follows the
[shared lifecycle](#playful-copy-lifecycle). The line never replaces an action,
-error, loading message or connection progress. Keep it on one line, apart from the actions; primary controls take
-precedence when space is tight.
+error, loading message or connection progress. Keep it on one line, apart from
+the actions; primary controls take precedence when space is tight. A clipped line
+may pan once to reveal its end; fitting text stays still.
+Pause with the copy's existing visibility owner. Reduced motion keeps the text
+stationary and manually scrollable instead of discarding the hidden words.
## Ownership And Incremental Review
@@ -548,5 +578,5 @@ moving the pointer away.
[Carbon loading guidance](https://carbondesignsystem.com/components/loading/usage/)
supports avoiding competing indicators and keeping required user actions distinct
-from passive loading. Piik's immediate captions and eight-second cadence are its
+from passive loading. Piik's selected waiting captions and fifteen-second cadence are its
own presentation choice.
diff --git a/docs/status.md b/docs/status.md
index 5070e0104..b5491cd5f 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -1,6 +1,6 @@
# Current Status
-Last updated: 2026-10-06
+Last updated: 2026-10-14
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
@@ -8,7 +8,9 @@ and Git/PRs own completed history.
## Accepted Release Contract
-- Browser/server v23, Native control v9 and capture v7 form the current contract.
+- The 2.0 release boundary uses Browser/server v24, Native control v10 and
+ capture v7. App and Site must upgrade together; 1.x cannot participate in a
+ 2.0 session. SQLite schema 2 is unchanged.
[Versioning](./standards/versioning.md#public-compatibility-promise) owns
compatibility between published Web/App/Server versions.
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
@@ -54,15 +56,31 @@ Release artifacts and operator deployment records own delivery completion;
## Current Work
-The public baseline retains Browser/server v23, Native control v9, capture v7,
-SQLite schema 2 and the 1440p ceiling. Hosted empty-room retention and capacity
-guidance preserve indefinite retention by default. The accepted Demo policy is
-one hour without authenticated participants; the private deployment retains the
-default. Operator records own activation, database and postflight evidence.
-4K and resolution/capability expansion are not planned for this phase.
-Native Magicsock remains a separate experiment, outside product integration.
-Immutable release artifacts own delivery completion; [TODO](./todo.md#now) owns
-remaining work.
+2.0 separates numeric media requests from presets and bounded runtime admission,
+retains successfully applied quality settings, and lets App serve an approved
+Site independently of Local/public room startup. It also adds detached chat on
+supporting desktop Browsers. Media, room authority and P2P-first routing keep
+their existing owners; [product standards](./standards/README.md) define the
+accepted behavior.
+
+Optional coordinated Native UDP preparation augments the existing retry owner.
+The [network laboratory](./research/network-lab.md) covers all Browser/App
+pairings, recovery, SFU policies and successful two-App preparation through
+sequential kernel NAT. These results do not establish a public success rate.
+Magicsock remains excluded: its [controlled comparison](./research/nat-traversal.md#integration-cost-and-recovery-controls)
+established no additional reachability worth its integration costs.
+
+The [media assessment](./research/native-client-media.md#media-capability-extension-assessment)
+owns bounded output and live-control evidence. Physical macOS/Linux capture,
+limited decoders and sustained game/4K use retain the limits in
+[verification status](./verification-status.md). Additional codecs, room voice,
+surround delivery and end-to-end HDR remain outside this phase.
+
+The owner authorized publication after final acceptance, without a separate
+field beta. Immutable release artifacts and operator records own exact delivery
+and postflight; [TODO](./todo.md#now) owns remaining product work. Hosted room
+retention stays indefinite by default. Demo uses one hour without authenticated
+participants; the private deployment keeps the default.
## Deployment
@@ -73,8 +91,8 @@ record own exact identity and postflight results.
The existing deployment uses one Go process for Web, room authority/signaling,
Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882. nginx owns
-HTTPS; external LiveKit/coturn services remain disabled. Participants, routes and
-media remain process-only. This private service is not a public demonstration.
+HTTPS. Participants, routes and media remain process-only. This private service
+is not a public demonstration.
The separate US [public demo](https://demo.piik.tv) is deployed with open site
entry, persistent room authority and P2P-only media. Cloudflare fronts HTTP/WSS
diff --git a/docs/todo.md b/docs/todo.md
index ed06b4afa..9075a41f5 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -1,25 +1,24 @@
# Current TODO Ledger
-Last reviewed: 2026-10-06
+Last reviewed: 2026-10-14
-Only **Now** is executable. Product modules own behavior; Git/PRs own completed
-history. A parked idea is not implementation authority.
+Explicit owner holds remain in force; an investigation does not itself
+accept a new feature or architecture. A missing device or reporter trace blocks
+only that evidence claim, not unrelated work. Product modules own behavior;
+Git/PRs own completed history.
## Now
-No active implementation work. Deferred proposals and evidence-dependent reports
-remain below; release delivery is tracked by the pipeline and deployment records.
+The 2.0 implementation scope is complete. Final package checks and delivery follow
+[the release workflow](../CONTRIBUTING.md#full-integration-and-release-workflow)
+and [GitHub operations](./operations/github.md); their exact results and PR
+disposition belong to the release/PR records. The owner authorized publication
+after bounded local acceptance, without a separate field beta. Broader device
+coverage below does not override the owner's platform deferrals or require a
+statistical NAT campaign. Do not describe those unmatched reports as fixed.
## Held By Owner
-- [ ] **4K and resolution/capability expansion.** Not planned for this phase,
- reaffirmed by the owner on 2026-10-06 because of cost relative to benefit. Keep the
- public v23/v9/v7 contract and 1440p ceiling. Resume the extensibility audit and
- [media extension assessment](./research/native-client-media.md#media-capability-extension-assessment)
- only with a renewed benefit/cost decision. This includes unfinished
- cross-platform capture and capability negotiation; local tests alone do not
- authorize a protocol break or a 2.0 release. Retain recoverable Git evidence
- for the experiment without requiring an active worktree.
- [ ] **Browser-internal encoder work.** Paused by the owner on 2026-10-01.
[Comparisons and Chromium traces](./research/browser-local-encoding-pool.md#sustained-h264-recovery)
own the evidence for synchronous H264 initialization, native adaptation and
@@ -34,52 +33,59 @@ remain below; release delivery is tracked by the pipeline and deployment records
- [ ] **Windows ARM64 App.** Deferred by the owner on 2026-10-03. Keep Windows
App packages and website downloads x64-only until this work is reopened.
-## Deferred Feature Work
-
-These proposals remain deferred beyond the accepted interaction phase.
-
-- [ ] **Native Magicsock integration.** The [isolated comparison](./research/nat-traversal.md#integration-cost-and-recovery-controls)
- establishes Go feasibility and relay-assisted recovery, but no additional
- direct-reachability class in its tested matrix. Keep product transport unchanged.
- Reopen integration only for a repeatable benefit on matched networks that
- justifies the extra identity, readiness, packet-size and relay responsibilities.
-- [ ] **Managed Demo hosting.** Evaluate cost and public UDP support before any
- migration. The assessment remains outside the current product release; keep
- the existing P2P-only Demo deployment until a provider and operating boundary
- are accepted.
-- [ ] **Phone as a Host microphone: assess after the current work.** Explore an
- opt-in link that pairs a phone's microphone with the Host's existing audio
- mixer. Evaluate pairing/revocation, latency/echo, browser background limits
- and Browser/native input ownership before accepting an implementation. This
- proposal does not reopen room voice or change the current release scope.
-- [ ] **Passive App attachment: design hold.** Site mode authorizes one selected
- origin and supplies native media without starting a local room server. A
- passive replacement needs an accepted site-consent/discovery flow; it must not
- admit arbitrary sites or add another runtime owner. Keep Site mode until that
- decision; removal of the Demo prefill does not authorize changing site trust.
-
## Awaiting Device Or Reporter Evidence
-These reports remain unmatched. A local repair or successful test is not proof
-of a reporter's cause; research owns the completed experiments and their limits.
+These physical checks or reporter diagnoses remain unverified. A local repair or
+successful test is not proof of a reporter's cause; research owns the completed
+experiments and their limits.
-- [ ] **Intermittent native capture self-test failure.** The worker check now
- records its phase, original exception and per-output progress. A local pass
- does not explain the earlier CI failure; use the next failed run's diagnostics
- before changing deadlines or worker behavior.
-- [ ] **macOS and Linux physical capture.** Complete the remaining capture/audio
- checks in [verification status](./verification-status.md#device-evidence-boundary),
- including an Intel Mac with a usable hardware H.264 encoder, an ARM64 Linux
- desktop, and Linux systems beyond the PR author's Hyprland/NVIDIA result.
-- [ ] **Signaling recovery on passwordless sites (#446).** Verify the admission
- correction against a matched Host/Server attempt; identify the event preceding
- the handshake failure rather than inferring password denial from `AUTH_REQUIRED`.
-- [ ] **HDR, audio-exclusion and mobile interaction device coverage.** Complete
- the [physical matrix](./verification-status.md#device-evidence-boundary):
- HDR/mixed displays, voice-app process trees across playback devices and phone
- keyboard/theater/participant-menu interaction. Browser HDR overexposure is an
- accepted limitation with [documented alternatives](./guide/troubleshooting.md#hdr-picture-looks-too-bright-or-washed-out);
- reopen conversion work only with new evidence and an accepted implementation.
+- [ ] **Self-hosted sustained quality loss (v1.9.2).** The report describes
+ resolution loss and stutter that clear after Viewer refresh, without paired
+ diagnostics, a confirmed route or codec. Six candidate
+ [sustained recovery trials](./research/network-lab.md#sustained-quality-recovery)
+ restore 720p on retained Browser P2P/SFU connections; recovery still takes
+ tens of seconds. Retest the affected setup with matched Host/Viewer/Server
+ reports before assigning its cause. Existing queue/probe repairs do not
+ establish a diagnosis, server CPU requirements or long-duration stability.
+
+- [ ] **Linux Portal target identity acceptance (#470).** The
+ [contributor fix](https://github.com/TNTcraftHIM/Piik/pull/470) is integrated
+ with authorship retained. Compilation and headless serial/node-ID targeting
+ checks pass. Verify an actual Portal that omits `pipewire-serial` when physical
+ Linux capture acceptance resumes; headless checks do not exercise desktop capture.
+- [ ] **Windows 11 capture border remains visible.** The new report does not
+ identify the affected version, capture path or Windows build. The reviewed
+ picker/start/source-change paths preserve the border preference; no additional
+ confirmed defect was found. [Capture evidence](./research/native-client-lifecycle.md#windows-capture-borders)
+ owns the permission correction, retirement checks and preview-cancellation
+ boundary. Visible permission changes, display-mode changes and the reporter's
+ setup still need matched physical evidence; API success does not prove that
+ Windows hid the border.
+- [ ] **Physical device and network acceptance.** Complete the remaining
+ [device checks](./verification-status.md#device-evidence-boundary) and
+ [network/endurance matrix](./verification-status.md#remaining-device-and-network-acceptance),
+ preserving platform deferrals and serial physical checks. The implemented
+ [numeric-media candidate](./research/native-client-media.md#media-capability-extension-assessment)
+ still needs physical limited-decoder, camera-orientation, macOS geometry and
+ sustained 4K/game evidence. Check live changes and source replacement through
+ relay/SFU, and listen across the
+ [audio control range](./research/browser-screen-audio-quality.md#numeric-bitrate-assessment).
+ Include existing mono/stereo and Windows HDR-to-SDR under the
+ [fidelity boundary](./research/media-fidelity.md), audio exclusion across real
+ process trees/playback devices, and mobile keyboard/theater/participant controls.
+ Passive App attachment still needs cross-device Browser-permission checks;
+ retain first/saved approval, replacement/revocation and Local coexistence.
+ These are evidence limits for implemented features, not new feature proposals.
+ Additional codecs, surround delivery and end-to-end HDR remain outside the candidate. The
+ [camera and microphone assessment](./research/camera-and-microphone.md)
+ additionally owns levels, echo, device replacement/native mixing, OBS input
+ A/V synchronization and phone camera permission/orientation/background limits.
+- [ ] **Virtual-microphone echo (#473).** The screenshot has Piik's microphone
+ off while screen audio excludes a different voice app. Process exclusion captures
+ other renderers across output devices, so a virtual mixer can still enter source
+ audio. Obtain the reporter's system-sound-off comparison and routing before
+ attributing the duplicate voice to Piik's mixer. The [audio guide](./guide/getting-started.md#exclude-a-voice-app-from-screen-audio)
+ explains this boundary; no automatic extra exclusion is planned.
- [ ] **Native H.264 motion quality (#432).** Obtain matched source/decoded
pictures and bitstream for the original 720p30 game comparison. Retain current
codec parameters. The [evidence review](./research/native-client-media.md#native-h264-motion-quality)
@@ -92,15 +98,30 @@ of a reporter's cause; research owns the completed experiments and their limits.
- [ ] **No available media route.** Compare the same endpoints/networks before
assigning a regression since v1.4. Obtain paired Host/Viewer/App reports and
distinguish route allocation, ICE, first-frame admission and route rejection.
+ Retest the public-invitation STUN report with ordinary discovery redundancy;
+ its endpoint timeout results lack paired ICE traces and the prediction setting.
[#443](https://github.com/TNTcraftHIM/Piik/issues/443) lacks a connected Native
edge and its paired Viewer/App transport report;
[#448](https://github.com/TNTcraftHIM/Piik/issues/448) only reaches route allocation.
+ [#453](https://github.com/TNTcraftHIM/Piik/issues/453) has no matched endpoint
+ reports to distinguish network reachability from a product failure.
The v1.6.3 candidate-timeout report lacks share-start and SDP/ICE evidence.
-- [ ] **Camera and Host microphone device coverage.** Check real audio levels,
- echo, device replacement/native mixing and phone camera permission, orientation
- and background behavior. Include OBS virtual-camera/audio-input sound quality
- and A/V synchronization. The [capture assessment](./research/camera-and-microphone.md)
- owns current behavior and the accepted physical limits.
+ [#468](https://github.com/TNTcraftHIM/Piik/issues/468) needs the complete join
+ trace to distinguish a new Viewer from reauthentication with lost local media;
+ its excerpt starts with an already committed SFU edge. Retest the affected
+ deployment with the repaired retained-connection opportunity accounting.
+ [#467](https://github.com/TNTcraftHIM/Piik/issues/467)'s Viewer report reaches
+ four preparations and exhaustion in about 170 ms, without local peer creation
+ or a Viewer failure report. Obtain the paired Host/App rejection before
+ attributing this attempt to NAT or extending its network timeout.
+ [#465](https://github.com/TNTcraftHIM/Piik/issues/465) reports Chrome/Firefox
+ LAN asymmetry, local-pool timeout and separate public-path quality loss.
+ Its successful repeat changed both local-network permission and IP masking.
+ [Local controls](./research/browser-local-encoding-pool.md#local-connection-failure-control)
+ retain P2P through producer timeout; isolate the settings and compare paired
+ reports before changing routing or advising users to disable IP masking.
+ [#463](https://github.com/TNTcraftHIM/Piik/issues/463) lacks the Browser-only
+ Mac/phone attempt details needed to distinguish capture, entry and media failure.
- [ ] **Public-invitation startup and HTTP 1033.** Obtain version and matched
diagnostics for creation failures, Demo access and a stream stalling before
reload returns 1033. Distinguish connector recovery/exit, DNS/provider failure
@@ -109,16 +130,17 @@ of a reporter's cause; research owns the completed experiments and their limits.
the [runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup).
[#434](https://github.com/TNTcraftHIM/Piik/issues/434) establishes DNS refusal
during edge discovery, not a cause shared by all startup reports.
+ [#452](https://github.com/TNTcraftHIM/Piik/issues/452) records both QUIC and TCP
+ connection timeouts; compare the same endpoint with/without its accelerator.
+ That startup log contains no evidence for its separate H.264 report.
+ [#466](https://github.com/TNTcraftHIM/Piik/issues/466) supplies only an early-exit
+ screenshot. Retaining dependency failure details improves the next report;
+ it does not establish or resolve this attempt's cause.
- [ ] **Viewer interruptions.** Obtain paired endpoint/upstream-relay diagnostics
- for established viewing returning to P2P connecting, including
- [#429](https://github.com/TNTcraftHIM/Piik/issues/429)'s SFU-to-P2P dropout.
+ for established viewing returning to P2P connecting.
Separately retest the v1.5.0 App public-link report where one Viewer drops
immediately after connection details appear while others work. Distinguish
first-frame admission, signaling grace and current-edge recovery.
-- [ ] **Windows 11 capture border remains visible.** Identify the App/Browser
- capture path, Windows build, permission result and other active captures.
- [Capture evidence](./research/native-client-lifecycle.md#windows-capture-borders)
- owns the graceful-retirement and concurrent-capture checks.
- [ ] **App discovery and share-start failures.** Retest missing sources and
unreachable App reports across authorization, browser permissions, control
capacity, enumeration and startup. For the v1.6.5 Auto-to-VP8 report, obtain
@@ -151,6 +173,20 @@ of a reporter's cause; research owns the completed experiments and their limits.
## Next: P2P Connection And Feedback Evidence
+- [ ] **Coordinated Native retry acceptance.**
+ The optional two-App preparation is integrated into the existing retry owner.
+ The [process laboratory](./research/network-lab.md) covers successful adoption
+ through sequential kernel NAT; ordinary continuation when preparation is
+ ineligible also passes. Verify successful preparation through physical difficult
+ networks and decoded-picture continuity on that path, following the
+ [assessment](./research/nat-traversal.md#coordinated-native-punching-assessment).
+ Preserve the first ordinary attempt, immediate Browser decline and fixed
+ operation deadlines. Future field comparisons should use the same peers with
+ the share's enhancement switch
+ enabled/disabled, including mixed stable/sequential observations in both
+ directions. Local coverage does not establish their field benefit. Random-port
+ overlap remains separate and unproven.
+
For the transport assessment, measure connection success, time to first picture and
failure causes on representative networks, especially App and P2P-only sites.
Use existing Debug provenance and selected-path events under the
@@ -160,60 +196,61 @@ to connection generations without raw endpoints. Verify survey responses and
P2P/SFU handoffs, including background direct attempts behind working SFU media.
The [iroh/Tailscale comparison](./research/nat-traversal.md#iroh-and-tailscale-comparison)
records the isolated Go media/migration experiment and Browser/relay limits.
-Before adopting magicsock in the product, establish a benefit on matched network
-pairs and define room-authorized peer keys, endpoint exchange, MTU/resource limits
-and mixed Browser/Native behavior. Keep the experiment isolated from the
-production [routing contract](./standards/routing-transport.md); Go reuse already
-works, so a Rust alternative needs a concrete Go limitation.
+The reopened optional background-App assessment includes current stable
+Magicsock/Pion controls; no additional direct reachability was established.
+Keep Magicsock outside the candidate. A future matched difficult-network
+comparison may reopen adoption, subject to the recorded discovery, datagram,
+authorization and relay-policy boundaries. Saved Site approval alone is not
+evidence for a transport benefit.
+Prioritize one-App-to-Browser and Browser-to-Browser controls using existing
+ICE, IPv6 and gateway mapping, with changing interfaces, lost mappings and
+asymmetric network restrictions. The
+[Browser-compatible assessment](./research/nat-traversal.md#browser-compatible-follow-up)
+owns current literature and applicability. Do not infer a benefit from another
+stack's success percentage or add a new retry/transport owner without evidence.
+The candidate's stable Pion upgrade has local Browser resolution and
+interoperability/recovery evidence in that assessment. Include the upgraded
+Native/SFU composition in physical-network acceptance; local success is not a
+measured increase in field hole-punching success.
+The [renomination preflight](./research/nat-traversal.md#pion-renomination-preflight)
+rejects enabling the current automatic option: Firefox's recovered video did not
+restore reverse feedback. Keep standard ICE recovery while that boundary remains.
## Parked Product Work
-1. **Representative device/network acceptance.** Resume the remaining matrix in
- [verification status](./verification-status.md#remaining-device-and-network-acceptance)
- when directed, preserving platform deferrals and serial physical checks.
- Before reusing the optional `viewer-mbb` canary, replace its obsolete
- Viewer-only trigger and capacity assertions with the sender-owned contract.
-2. **Broader quality work.** Reopen from measured interruptions, failed recovery
+1. **Broader quality work.** Reopen from measured interruptions, failed recovery
or healthy-sibling degradation at acceptable complexity. The
[Browser pool comparison](./research/browser-local-encoding-pool.md#balanced-startup-and-recovery)
owns cold-encoder tradeoffs; low resolution alone is not a defect. Check
whether a proposed move merely shifts pressure to another parent's siblings.
-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.
-4. **Reachable ownership/refactor work.** With related behavior changes, evaluate
- per-share Host and Viewer route ownership under
- [engineering review](./standards/engineering.md#ablation-and-review).
- Reopen C=3 structural-intent retention or multi-child evidence ownership only
- with current-contract reproductions. Shared recovery must preserve Native
- bridge versus network-failure semantics. Before changing Host loopback-failure
- retirement, account for its preview, Browser quality candidates, quality/SFU
- updates and track-ended consumers; independent Native publication alone does
- not make ignoring the failure safe. This is not a diagnosed field cause.
-5. **Storage fault recovery.** Choose and verify a damaged-disk/COMMIT/ROLLBACK
- recovery policy before adding catch-and-continue or retries. This failure
- boundary remains unestablished after ordinary persistence checks.
-6. **Platform output.** Reopen for a registered receiver acting as an ordinary
+2. **Platform output.** Reopen for a registered receiver acting as an ordinary
Viewer only after the [platform-output gate](./research/platform-output.md)
passes.
-7. **Additional languages.** Review community catalogs and their rendered UI
+3. **Additional languages.** Review community catalogs and their rendered UI
following the [translation guide](./guide/translating.md), including names,
menu navigation, text direction and layout.
-8. **Windows code signing.** Revisit after enrollment in a trusted signing
+4. **Windows code signing.** Revisit after enrollment in a trusted signing
service. Sign executables before archive checksums; publisher identity does
not guarantee that antivirus cloud scanning stops.
-9. **Gitee download-source warning.** Paused by the owner. Keep GitHub primary
+5. **Gitee download-source warning.** Paused by the owner. Keep GitHub primary
and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the
Gitee attachment when Referer is removed. Reopen for new evidence or a
provider review; the warning remains unresolved.
-10. **Release-operation policy.** Protected release environments, immutable
- draft assets and changing mirror-failure policy remain unaccepted proposals;
- evaluate their benefit before adding release machinery.
-11. **Automatic local chat history.** Explicit TXT export covers manual retention.
+6. **Release-operation policy.** Protected release environments and immutable
+ draft assets remain unaccepted proposals; evaluate their benefit before
+ adding release machinery.
+7. **Automatic local chat history.** Explicit TXT export covers manual retention.
Reconsider automatic storage only with a stable room-incarnation identity,
bounded retention and a clear delete control; reusable room codes must not
combine conversations. No new history service or wire field is authorized.
-12. **Direct OBS input (#436).** Reopen for a measured virtual-camera limitation.
+8. **Direct OBS input (#436).** Reopen for a measured virtual-camera limitation.
The [capture assessment](./research/camera-and-microphone.md) records raw-frame
output and the WHIP boundary; virtual camera alone is not a second encode.
Do not add an RTMP/WHIP listener without an accepted publication owner.
+9. **Managed Demo hosting.** The [provider assessment](./operations/website.md#managed-hosting-assessment)
+ identifies UDP/storage requirements but establishes no cost saving. Keep the
+ existing P2P-only Demo; reopen for an accepted provider and operating boundary.
+10. **Phone as a Host microphone.** The [input assessment](./research/camera-and-microphone.md#phone-microphone-assessment)
+ identifies pairing/revocation and native decoded-input ownership beyond the
+ device selector. Reopen for a concrete use case that justifies this boundary
+ and physical latency/echo/background checks. This does not reopen room voice.
diff --git a/docs/verification-status.md b/docs/verification-status.md
index 379155b84..702009465 100644
--- a/docs/verification-status.md
+++ b/docs/verification-status.md
@@ -1,6 +1,6 @@
# Verification Status
-Last updated: 2026-10-03
+Last updated: 2026-10-14
This file owns physical evidence limits that change how the product may be
described. [Status](./status.md) owns the execution/deployment index.
@@ -10,8 +10,9 @@ implementation and obsolete package history.
## Current Evidence
Windows App and Browser are the owner's primary acceptance targets. Current
-Go packages embed their Web assets and do not run a Node backend. The current
-contract is Browser/server v23, Native control v9 and capture v7.
+Go packages embed their Web assets and do not run a Node backend.
+[Status](./status.md#accepted-release-contract) owns the accepted contract tuple
+and current release boundary; artifacts own the exact published build.
Bounded current checks cover:
diff --git a/go.mod b/go.mod
index a933186be..5fba68043 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/TNTcraftHIM/Piik
-go 1.26.0
+go 1.27.2
// Scoped NAT-PMP fixes; provenance and removal criteria: internal/thirdparty/README.md.
replace github.com/netbirdio/go-nat => ./internal/thirdparty/go-nat
@@ -16,37 +16,38 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/go-logr/logr v1.4.4
- github.com/livekit/livekit-server v1.13.6
+ github.com/livekit/livekit-server v1.13.9
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095
- github.com/livekit/protocol v1.50.5-0.20260821144838-17c16cf496fd
+ github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a
github.com/livekit/server-sdk-go/v2 v2.14.0
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8
- github.com/pion/ice/v4 v4.4.0
- github.com/pion/interceptor v0.1.47
+ github.com/pion/ice/v4 v4.4.7
+ github.com/pion/interceptor v0.1.49
github.com/pion/logging v0.2.4
- github.com/pion/rtcp v1.2.17
+ github.com/pion/rtcp v1.2.19
github.com/pion/rtp v1.10.5
- github.com/pion/sdp/v3 v3.0.19
- github.com/pion/stun/v3 v3.1.7
- github.com/pion/transport/v4 v4.1.1
- github.com/pion/turn/v5 v5.0.13
- github.com/pion/webrtc/v4 v4.2.19
- github.com/thesyncim/gopus v0.1.1
+ github.com/pion/sdp/v3 v3.0.20
+ github.com/pion/stun/v4 v4.0.1
+ github.com/pion/transport/v5 v5.1.1
+ github.com/pion/turn/v5 v5.1.2
+ github.com/pion/webrtc/v4 v4.2.23
+ github.com/thesyncim/gopus v0.2.2
go.uber.org/zap v1.28.0
- golang.org/x/crypto v0.54.0
- golang.org/x/mod v0.38.0
- golang.org/x/net v0.57.0
- golang.org/x/sync v0.22.0
- golang.org/x/text v0.41.0
- google.golang.org/protobuf v1.36.11
+ golang.org/x/crypto v0.57.0
+ golang.org/x/mod v0.41.0
+ golang.org/x/net v0.60.0
+ golang.org/x/sync v0.23.0
+ golang.org/x/text v0.42.0
+ google.golang.org/protobuf v1.36.12
modernc.org/sqlite v1.58.0
)
require (
- buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect
- buf.build/go/protovalidate v1.2.0 // indirect
+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
+ buf.build/go/protovalidate v1.4.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
- cel.dev/expr v0.25.2 // indirect
+ cel.dev/cel-go v0.32.0 // indirect
+ cel.dev/expr v0.25.3 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -76,36 +77,38 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.1 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
- github.com/klauspost/compress v1.19.1 // indirect
+ github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/koron/go-ssdp v0.0.4 // indirect
github.com/libp2p/go-netroute v0.2.1 // indirect
- github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
+ github.com/lithammer/shortuuid/v4 v4.3.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
- github.com/livekit/psrpc v0.7.3 // indirect
+ github.com/livekit/psrpc v0.8.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mackerelio/go-osstat v0.2.8 // indirect
+ github.com/magefile/mage v1.17.2 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.6.1 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/nats-io/nats.go v1.52.0 // indirect
+ github.com/nats-io/nats.go v1.53.1 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 // indirect
- github.com/pion/datachannel v1.6.2 // indirect
- github.com/pion/dtls/v3 v3.1.5 // indirect
- github.com/pion/mdns/v2 v2.1.0 // indirect
+ github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
+ github.com/pion/datachannel v1.6.3 // indirect
+ github.com/pion/dtls/v3 v3.1.10 // indirect
+ github.com/pion/mdns/v2 v2.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
- github.com/pion/sctp v1.11.1 // indirect
- github.com/pion/srtp/v3 v3.0.13 // indirect
+ github.com/pion/sctp v1.12.0 // indirect
+ github.com/pion/srtp/v3 v3.1.3 // indirect
+ github.com/pion/transport/v4 v4.1.1 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
- github.com/prometheus/client_model v0.6.2 // indirect
- github.com/prometheus/common v0.70.1 // indirect
- github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/prometheus/client_model v0.6.3 // indirect
+ github.com/prometheus/common v0.72.0 // indirect
+ github.com/prometheus/procfs v0.22.0 // indirect
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -115,19 +118,20 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/otel v1.45.0 // indirect
- go.opentelemetry.io/otel/metric v1.45.0 // indirect
- go.opentelemetry.io/otel/trace v1.45.0 // indirect
- go.uber.org/atomic v1.11.0 // indirect
+ go.opentelemetry.io/otel v1.47.0 // indirect
+ go.opentelemetry.io/otel/log v1.47.0 // indirect
+ go.opentelemetry.io/otel/metric v1.47.0 // indirect
+ go.opentelemetry.io/otel/trace v1.47.0 // indirect
+ go.uber.org/atomic v1.12.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
- golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
- golang.org/x/sys v0.47.0 // indirect
- golang.org/x/time v0.15.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
- google.golang.org/grpc v1.83.0 // indirect
+ golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/time v0.16.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect
+ google.golang.org/grpc v1.84.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 0a1c4bcda..99d433e09 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,19 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
+buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
+buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
+cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
+cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
+cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
@@ -79,6 +87,7 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/florianl/go-tc v0.4.8 h1:hgmakUX1Nm0Ba1I0ZkbUl9CH6HbRwqSiwipnpmYp3Es=
github.com/florianl/go-tc v0.4.8/go.mod h1:B8GeOEnmrbOnxZtaCvsYJcgIzzmM8c/AIhtfCZsDj3Q=
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
@@ -151,6 +160,8 @@ github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
+github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
@@ -168,22 +179,32 @@ github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9t
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
+github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA=
+github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/livekit-server v1.13.6 h1:vvLufsmzMG9ueZ6BqSahasC8rUH1toSv4979fOY9uQI=
github.com/livekit/livekit-server v1.13.6/go.mod h1:Dxd+bHclL+F08/T8TlZkxjSRYXCHObLo+ujPRc7h7MM=
+github.com/livekit/livekit-server v1.13.9 h1:HPKsWMDF+fXWX7mrXD4Cul5L9X8dSZ6KOjXlBm1m4tk=
+github.com/livekit/livekit-server v1.13.9/go.mod h1:yF0PoK9MxarCECGVj/MWKsEbH9t4SQmn1CL0dEXQ3Ks=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.50.5-0.20260821144838-17c16cf496fd h1:pGWIR9fw1eC699v2njRX/7Y+baEXB7p6k9CvDg2uVrQ=
github.com/livekit/protocol v1.50.5-0.20260821144838-17c16cf496fd/go.mod h1:h4wP4IBPkT5aew7SZSGViHSmanKAiKWsHij/5YfL4BE=
+github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a h1:Sa9+0Kr+MVIabmJaBFtX8Yl3NYewkv02/mMDvLEKYiQ=
+github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4=
github.com/livekit/psrpc v0.7.3 h1:bekuZt/ZQzg8+/M8G6G5jq7bvV9fAKdPHSOZeTwrIIc=
github.com/livekit/psrpc v0.7.3/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
+github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc=
+github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q=
github.com/livekit/server-sdk-go/v2 v2.14.0 h1:yy7LsR1+64DHC2i4DxZe1xX4ScVq+S3CvgpvHRruEec=
github.com/livekit/server-sdk-go/v2 v2.14.0/go.mod h1:+HCKTpzV21b/jvBtu+OmWbquUxaL74kHLI9ZwKmdhKU=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4=
github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k=
+github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
+github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -213,14 +234,18 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
+github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
+github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
+github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -235,50 +260,58 @@ github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoP
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 h1:VmZ6mKVkxavKEhEy4ZYyV7BwBYBFBP0TwIqmLk84fpU=
github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
-github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
-github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
-github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
-github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
-github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
-github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
-github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
-github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pion/datachannel v1.6.3 h1:HZAYLpunVI0xi057ZiANl558d0uSpomcWw4UEPz3qXs=
+github.com/pion/datachannel v1.6.3/go.mod h1:oRmkXVVXxkUbqa8MatgdNHW29kvtQY3HUdjJQsfv2GE=
+github.com/pion/dtls/v3 v3.1.10 h1:HWC+QCZitP/ApADS/6+g7UIw2YmLgoK3CsynnjPJgMo=
+github.com/pion/dtls/v3 v3.1.10/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc=
+github.com/pion/ice/v4 v4.4.7 h1:JiI6VId3XnIztxKE8RjTC0SeXrp7ppkAO0jGez3/sIE=
+github.com/pion/ice/v4 v4.4.7/go.mod h1:zCRRg4Mkpdbx9B+75fd4z2APvIXeIraeUOB7IMPt8mU=
+github.com/pion/interceptor v0.1.49 h1:iyBsNHRoLNNXZiJA/DdGvJgdyfS8YWDCzxIAlVjt5nY=
+github.com/pion/interceptor v0.1.49/go.mod h1:MZ6PJkja/TCo350HAnBrs/rUIyad9mWjpcvytrf3ViQ=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
-github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
-github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
+github.com/pion/mdns/v2 v2.2.2 h1:qq9Kj3PnjF0mfSwd8Icu0CXy5aoYdQKnNUIsGPeUpgk=
+github.com/pion/mdns/v2 v2.2.2/go.mod h1:ZX5f0AAH1D6TOjdjvcBORZZHaZuG0t9+br78lHEwiJ4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
-github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
-github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
+github.com/pion/rtcp v1.2.19 h1:T79lR2Tfd/7zj+41nsUrftrUEee+Z03NQr9GSPSQ/WU=
+github.com/pion/rtcp v1.2.19/go.mod h1:vX7Es6skWYm4oVvOSC5+4WHLYUTZHcbGNmk5wjzMGH4=
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
-github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
-github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
-github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
-github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
-github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q=
-github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY=
-github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
-github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
-github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
-github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/sctp v1.12.0 h1:AzUdVkVBNyGY1W4crWO1/aOWzMaW3zsVHGrNuAfFQiI=
+github.com/pion/sctp v1.12.0/go.mod h1:6k1zrpkrxSwbisitn/AlzDQaa15DR49MFsrMochG/yc=
+github.com/pion/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ=
+github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q=
+github.com/pion/srtp/v3 v3.1.3 h1:O3sfbfWfq/OyEaXRbX02Z0SNQRijS9nDdc/DEb8jxlU=
+github.com/pion/srtp/v3 v3.1.3/go.mod h1:v3uQtrCz9LH3aahau0jtzRDsWGwKkh4a0BiBwzzxhtQ=
+github.com/pion/stun/v4 v4.0.1 h1:S2ggK4hsUJJKoZfpL90uIqIRTj6xJ0NOIEkXgcpQif0=
+github.com/pion/stun/v4 v4.0.1/go.mod h1:byktbPA8U7HjJ2H8w3moP7nzq0Q+kYNulvMLPaubQL4=
github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo=
github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
-github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4=
-github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A=
-github.com/pion/webrtc/v4 v4.2.19 h1:2usG6s7eXMF08tqqoP3A4CX5XHArZsi1qeXDIIvXMeE=
-github.com/pion/webrtc/v4 v4.2.19/go.mod h1:jWMn3KOGxyYFYJohzIHhBc4VrJiyS+94A1OfErYzN34=
+github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8=
+github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0=
+github.com/pion/turn/v5 v5.1.2 h1:acEOO+D5txJ52vKfcilmSMItfP23Q9K6pJ1LM5P2VV0=
+github.com/pion/turn/v5 v5.1.2/go.mod h1:57Oadc8fHnac15VFyCd6nEaBTVZBxQe2POUMD4lsHRA=
+github.com/pion/webrtc/v4 v4.2.23 h1:AlVixhTtQmxrheSv6lAH0w2sH5qjM7OTos5Lt811nk0=
+github.com/pion/webrtc/v4 v4.2.23/go.mod h1:vzf5RQ0uMBuXAYTK2pabZUtnZyj2t3TF88eL4xzQZvI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
+github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
+github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
+github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
@@ -296,8 +329,8 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
-github.com/thesyncim/gopus v0.1.1 h1:SM58S7VZGnEZ3ZR0pX5AoC33//LBxJhKkvm15MbBmik=
-github.com/thesyncim/gopus v0.1.1/go.mod h1:orRqwrGs5gqYRRnhqwI0Y3liqQTeDkreUpra+Kv9bQc=
+github.com/thesyncim/gopus v0.2.2 h1:Ib8mmsLBon6ylXVk6p7fLxuZNnBzfPA4wT4GBzfWqCI=
+github.com/thesyncim/gopus v0.2.2/go.mod h1:uENpqeR6V8WrMhH5vLJARzFgpMgXJBl5M0cg54LgoA0=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@@ -312,18 +345,30 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
+go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
+go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
+go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
+go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
+go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
+go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
+go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
+go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
+go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -339,14 +384,20 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
+golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
+golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 h1:qUp3i6M39kccSm3OMMIE3tvs7YQWO9rFeCKdk1sSaFk=
+golang.org/x/exp v0.0.0-20261005173118-76772065c9b0/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
+golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -362,13 +413,17 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
-golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
+golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -394,6 +449,8 @@ golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -402,12 +459,17 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -415,12 +477,20 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
+google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0=
+google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
-google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
+google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
+google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/internal/app/app.go b/internal/app/app.go
index f65036af5..c7c228bf3 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"syscall"
"time"
@@ -20,21 +21,23 @@ import (
"github.com/TNTcraftHIM/Piik/internal/app/lan"
"github.com/TNTcraftHIM/Piik/internal/app/launcher"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativeaudio"
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
"github.com/TNTcraftHIM/Piik/internal/app/nativecontrol"
"github.com/TNTcraftHIM/Piik/internal/app/publictunnel"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
serverapp "github.com/TNTcraftHIM/Piik/internal/server/app"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
"github.com/TNTcraftHIM/Piik/internal/server/webassets"
"golang.org/x/mod/semver"
)
const (
- DefaultLocalPort = 8787
- publicSTUNURL = "stun:stun.cloudflare.com:3478"
- publicNATPredictionSTUNURLA = "stun:stun.miwifi.com:3478"
- publicNATPredictionSTUNURLB = "stun:stun.chat.bilibili.com:3478"
+ DefaultLocalPort = 8787
+ publicSTUNURL = "stun:stun.cloudflare.com:3478"
+ publicFallbackSTUNURLA = "stun:stun.miwifi.com:3478"
+ publicFallbackSTUNURLB = "stun:stun.chat.bilibili.com:3478"
// localShutdownTimeout bounds the ordered Local stop. The packaged smoke
// allows 10 s between the stop request and the exit, so it stays well
// inside that budget.
@@ -75,9 +78,12 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
options.Debug = options.Debug || debugEnabled(os.Getenv("PIIK_DEBUG"))
options.console = newConsole(cancel, options.DisableBrowser)
var recorder *diagnostics.Recorder
+ var diagnosticsMu sync.Mutex
var restoreLogger func()
defer func() {
cancel()
+ diagnosticsMu.Lock()
+ defer diagnosticsMu.Unlock()
returnedErr = finishRun(options.console, recorder, restoreLogger, returnedErr)
}()
enableDiagnostics := func() error {
@@ -158,11 +164,16 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
"softwareVP8", nativeMedia.capabilities.SoftwareVP8, diagnostics.Error(nativeMedia.discoveryErr))
}
recordContext(options, config)
+ qualityPreference, preferenceErr := appconfig.LoadQualityPreference(configPath)
+ if preferenceErr != nil {
+ slog.Debug("piik-client", "event", "quality-preference-load-failed", diagnostics.Error(preferenceErr))
+ }
control, err := loopback.Start(ctx, loopback.Options{
- AllowedOrigins: allowedOrigins(config.Site, options.Port),
- NativeMedia: nativeMedia.capabilities,
- NewControl: nativeMedia.controlFactory(),
- Presentation: options.console.setLanguage,
+ AllowedOrigins: allowedOrigins(config.Site, options.Port),
+ NativeMedia: nativeMedia.capabilities,
+ NewControl: nativeMedia.controlFactory(),
+ Presentation: options.console.setLanguage,
+ QualityPreference: qualityPreference,
})
if err != nil {
return fmt.Errorf("Piik App native control could not start: %w", err)
@@ -174,19 +185,24 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
return err
}
}
- if !explicitMode(options) {
- return runLauncher(ctx, options, configPath, config, control, func(selected *Options, config appconfig.Config) error {
- if selected.Debug {
- if err := enableDiagnostics(); err != nil {
- return err
- }
- selected.logger = recorder.Logger()
+ return runLauncher(ctx, options, configPath, config, control, func(selected *Options, config appconfig.Config) error {
+ diagnosticsMu.Lock()
+ defer diagnosticsMu.Unlock()
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if selected.Debug {
+ if err := enableDiagnostics(); err != nil {
+ return err
}
- recordContext(*selected, config)
- return nil
- })
- }
- return runConfigured(ctx, options, config, control)
+ }
+ if recorder != nil {
+ selected.Debug = true
+ selected.logger = recorder.Logger()
+ }
+ recordContext(*selected, config)
+ return nil
+ })
}
func finishRun(console *console, recorder *diagnostics.Recorder, restoreLogger func(), err error) error {
@@ -211,18 +227,6 @@ func explicitMode(options Options) bool {
return options.SiteSet || options.Local || options.Link || options.DisableBrowser
}
-func runConfigured(
- ctx context.Context,
- options Options,
- config appconfig.Config,
- control *loopback.Server,
-) error {
- if config.Site != "" && !options.Local && !options.Link {
- return runSite(config.Site, options, control)
- }
- return runLocal(ctx, options, config, control)
-}
-
func runLauncher(
ctx context.Context,
options Options,
@@ -231,10 +235,30 @@ func runLauncher(
control *loopback.Server,
configureDiagnostics func(*Options, appconfig.Config) error,
) (returnedErr error) {
+ automatic := explicitMode(options)
+ directRoom := automatic && (options.Local || options.Link || config.Site == "")
+ initialOptions := options
+ initialConfig := config
mode, err := launcher.LoadMode(configPath)
if err != nil {
slog.Debug("Could not restore the last launcher mode", diagnostics.Error(err))
}
+ // Site approval and room startup mutate separate fields of one config.
+ // Serialize persistence and publish only the complete successfully saved value.
+ var configMu sync.Mutex
+ launcherOrigin := ""
+ updateConfig := func(change func(*appconfig.Config)) (appconfig.Config, error) {
+ configMu.Lock()
+ defer configMu.Unlock()
+ next := config
+ change(&next)
+ if err := appconfig.Save(configPath, next); err != nil {
+ return config, err
+ }
+ config = next
+ err := control.SetAllowedOrigins(append(allowedOrigins(config.Site, initialOptions.Port), launcherOrigin))
+ return config, err
+ }
launch, err := launcher.Start(
ctx,
webassets.FS(),
@@ -243,103 +267,142 @@ func runLauncher(
Site: config.Site, Version: buildVersion(), Revision: BuildRevision,
LocalAccessPassword: config.LocalAccessPassword,
Debug: options.Debug, LANAddress: options.LANAddress,
+ ChangeSite: func(site string) (string, error) {
+ current, err := updateConfig(func(next *appconfig.Config) { next.Site = site })
+ return current.Site, err
+ },
+ EnableDebug: func() error {
+ configMu.Lock()
+ defer configMu.Unlock()
+ selected := initialOptions
+ selected.Debug = true
+ return configureDiagnostics(&selected, config)
+ },
},
)
if err != nil {
return err
}
- defer launch.Close()
+ defer func() { returnedErr = errors.Join(returnedErr, launch.Close()) }()
+ control.SetConnectURL(launch.URL())
+ defer control.SetConnectURL("")
launchAddress, err := url.Parse(launch.URL())
if err != nil {
return err
}
- control.SetAllowedOrigins(append(
- allowedOrigins(config.Site, options.Port),
- launchAddress.Scheme+"://"+launchAddress.Host,
- ))
- options.console.show(consoleView{state: "setup", entry: launch.URL()})
- options.console.openBrowser(launch.URL())
+ configMu.Lock()
+ launcherOrigin = launchAddress.Scheme + "://" + launchAddress.Host
+ err = control.SetAllowedOrigins(append(allowedOrigins(config.Site, options.Port), launcherOrigin))
+ configMu.Unlock()
+ if err != nil {
+ return err
+ }
+ if !automatic {
+ options.console.show(consoleView{state: "setup", entry: launch.URL()})
+ options.console.openBrowser(launch.URL())
+ } else if initialConfig.Site != "" && !options.Local && !options.Link {
+ // --site is still an explicit approval shortcut. It supplies native
+ // capabilities immediately; the launcher can start a Local room later.
+ openSite(initialConfig.Site, options)
+ }
+ // An interactive room is a child of the resident App. Its failure must not
+ // retire an unrelated Site's native sessions or its approval page. Explicit
+ // CLI launches still return their outcome to the caller. Call only after the
+ // room has retired, so neither approval nor process exit depends on its work.
+ finishRoom := func(roomErr error) error {
+ if directRoom || roomErr == nil || ctx.Err() != nil {
+ return roomErr
+ }
+ launch.SetResult("", roomErr)
+ options.console.show(consoleView{state: "setup", entry: launch.URL(), problem: diagnostics.SafeText(roomErr.Error())})
+ slog.Debug("piik-client", "event", "local-room-stopped", diagnostics.Error(roomErr))
+ select {
+ case <-ctx.Done():
+ case err := <-control.Done():
+ roomErr = errors.Join(roomErr, err)
+ case err := <-launch.Done():
+ roomErr = errors.Join(roomErr, err)
+ }
+ return roomErr
+ }
var selection launcher.Selection
- select {
- case selection = <-launch.Selection():
- case <-ctx.Done():
- return nil
- case err = <-launch.Done():
- if err != nil {
- return fmt.Errorf("Piik App launcher stopped unexpectedly: %w", err)
+ if directRoom {
+ selection = launcher.Selection{Mode: launcher.ModeLocal, LocalAccessPassword: initialConfig.LocalAccessPassword}
+ if options.Link {
+ selection.Mode = launcher.ModeLink
+ }
+ launch.Begin()
+ } else {
+ select {
+ case selection = <-launch.Selection():
+ case <-ctx.Done():
+ return nil
+ case err = <-control.Done():
+ return err
+ case err = <-launch.Done():
+ if err != nil {
+ return fmt.Errorf("Piik App launcher stopped unexpectedly: %w", err)
+ }
+ return nil
}
- return nil
}
- options.console.setLanguage(selection.Language)
+ if selection.Language != "" {
+ options.console.setLanguage(selection.Language)
+ }
if err := launcher.SaveMode(configPath, selection.Mode); err != nil {
slog.Debug("Could not save the launcher mode", diagnostics.Error(err))
}
- if selection.Mode == launcher.ModeSite {
- config.Site = selection.Site
- } else {
- config.LocalAccessPassword = selection.LocalAccessPassword
- }
options.Local = selection.Mode == launcher.ModeLocal
options.Link = selection.Mode == launcher.ModeLink
options.Debug = options.Debug || selection.Debug
if selection.LANAddress != nil {
options.LANAddress = *selection.LANAddress
}
- if err = configureDiagnostics(&options, config); err != nil {
- launch.SetResult("", err)
- <-launch.Handled()
- return err
- }
- if err = appconfig.Save(configPath, config); err != nil {
+ selectedConfig, err := updateConfig(func(next *appconfig.Config) {
+ next.LocalAccessPassword = selection.LocalAccessPassword
+ })
+ if err != nil {
err = fmt.Errorf("Piik App configuration could not be saved: %w", err)
- launch.SetResult("", err)
- <-launch.Handled()
- return err
+ return finishRoom(err)
+ }
+ if err = configureDiagnostics(&options, selectedConfig); err != nil {
+ return finishRoom(err)
+ }
+ if !directRoom {
+ options.DisableBrowser = true
}
- control.SetAllowedOrigins(allowedOrigins(config.Site, options.Port))
- options.DisableBrowser = true
ready := make(chan string, 1)
- options.Ready = func(target string) { ready <- target }
+ onReady := options.Ready
+ options.Ready = func(target string) {
+ if onReady != nil {
+ onReady(target)
+ }
+ ready <- target
+ }
runtimeContext, cancelRuntime := context.WithCancel(ctx)
runtimeDone := make(chan struct{})
var runtimeErr error
go func() {
- runtimeErr = runConfigured(runtimeContext, options, config, control)
+ runtimeErr = runLocal(runtimeContext, options, selectedConfig, control)
close(runtimeDone)
}()
- defer func() {
- cancelRuntime()
- <-runtimeDone
- returnedErr = errors.Join(returnedErr, runtimeErr)
- }()
-
+ var exitErr error
select {
case target := <-ready:
launch.SetResult(target, nil)
+ select {
+ case <-runtimeDone:
+ case exitErr = <-launch.Done():
+ case <-ctx.Done():
+ }
case <-runtimeDone:
- launch.SetResult("", runtimeErr)
- <-launch.Handled()
- return nil
case <-ctx.Done():
launch.SetResult("", ctx.Err())
- return nil
- }
-
- select {
- case <-launch.Handled():
- _ = launch.Close()
- case <-runtimeDone:
- return nil
- case <-ctx.Done():
- return nil
- }
- select {
- case <-runtimeDone:
- return nil
- case <-ctx.Done():
- return nil
}
+ cancelRuntime()
+ <-runtimeDone
+ return finishRoom(errors.Join(exitErr, runtimeErr))
}
func applyMode(config appconfig.Config, options Options) (appconfig.Config, error) {
@@ -394,11 +457,7 @@ func openDiagnostics(directory string) (*diagnostics.Recorder, error) {
return diagnostics.Open(filepath.Join(cache, "Piik", "logs"), "client", BuildRevision)
}
-// runSite opens the configured Piik Site in the Browser and waits for the
-// loopback server, which is the only thing this mode owns. It takes no context:
-// cancellation reaches it through control.Done().
-func runSite(site string, options Options, control *loopback.Server) error {
- var err error
+func openSite(site string, options Options) {
slog.Debug("piik-client", "event", "mode", "mode", "site")
targetURL := launchURL(site)
view := consoleView{mode: "site", state: "ready", entry: targetURL}
@@ -408,14 +467,11 @@ func runSite(site string, options Options, control *loopback.Server) error {
}
if !options.DisableBrowser {
options.console.openBrowser(targetURL)
- } else if options.Ready != nil {
+ }
+ if options.Ready != nil {
options.Ready(targetURL)
}
slog.Debug("piik-client", "event", "site-ready")
- if err = <-control.Done(); err != nil {
- return fmt.Errorf("Piik App native control stopped unexpectedly: %w", err)
- }
- return nil
}
func runLocal(ctx context.Context, options Options, config appconfig.Config,
@@ -526,7 +582,8 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
options.console.show(view)
if !options.DisableBrowser {
options.console.openBrowser(launchURL)
- } else if options.Ready != nil {
+ }
+ if options.Ready != nil {
options.Ready(launchURL)
}
var tunnelDone <-chan struct{}
@@ -621,30 +678,39 @@ func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRunti
captureProcess: path,
capture: capabilities,
capabilities: loopback.NativeMediaCapabilities{
- ReceiverReuse: true,
- Video: summary.Video,
- CaptureBorderControl: summary.CaptureBorderControl,
- Microphone: summary.Microphone,
- ProcessAudio: summary.ProcessAudio,
- ProcessAudioExclusion: summary.ProcessAudioExclusion,
- SystemAudio: summary.SystemAudio,
- HardwareH264: summary.HardwareH264,
- SoftwareVP8: summary.SoftwareVP8,
+ MaxVideoResolution: protocol.QualityResolution{
+ Width: nativecapture.MaxVideoDimension, Height: nativecapture.MaxVideoDimension,
+ },
+ MaxVideoPixels: nativecapture.MaxVideoPixels,
+ MaxVideoMacroblocksPerSecond: nativecapture.MaxVideoMacroblocksPerSecond,
+ MaxVideoFramerate: nativecapture.MaxVideoFramerate,
+ MaxVideoBitrate: nativecapture.MaxVideoBitrate,
+ MaxAudioBitrate: nativeaudio.MaxSendBitrate,
+ ReceiverReuse: true,
+ Video: summary.Video,
+ CaptureBorderControl: summary.CaptureBorderControl,
+ Microphone: summary.Microphone,
+ ProcessAudio: summary.ProcessAudio,
+ ProcessAudioExclusion: summary.ProcessAudioExclusion,
+ SystemAudio: summary.SystemAudio,
+ HardwareH264: summary.HardwareH264,
+ SoftwareVP8: summary.SoftwareVP8,
},
}
}
// localSTUNURLs is the Local room authority's ICE configuration. Only --link
// has a public path, so only --link configures public STUN and the bounded NAT
-// prediction survey. The lists are literals on purpose: a gate that sets
-// STUN_URLS is configuring the App's own Pion edge, and that value must not
-// reach the room server (which is why serverconfig.Local reads no environment).
+// prediction survey. Native edges receive ICE configuration from that authority;
+// serverconfig.Local deliberately does not inherit Hosted environment settings.
+// All three destinations also serve ordinary discovery: disabling prediction
+// must not make public-link ICE depend on a single STUN operator.
func localSTUNURLs(link bool) (stunURLs []string, natPredictionSTUNURLs []string) {
if !link {
return nil, nil
}
- return []string{publicSTUNURL},
- []string{publicNATPredictionSTUNURLA, publicNATPredictionSTUNURLB}
+ return []string{publicSTUNURL, publicFallbackSTUNURLA, publicFallbackSTUNURLB},
+ []string{publicFallbackSTUNURLA, publicFallbackSTUNURLB}
}
// endLocalServer is step 1 of the ADR-0010 item 12 unwind: every room ends,
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index 2b17851cc..0196130f5 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log"
"log/slog"
"net"
@@ -22,7 +23,6 @@ import (
tea "charm.land/bubbletea/v2"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
"github.com/TNTcraftHIM/Piik/internal/app/lan"
- "github.com/TNTcraftHIM/Piik/internal/app/launcher"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
@@ -226,7 +226,7 @@ func TestLauncherRetainsItsRuntimeFailureDuringCancellation(t *testing.T) {
go func() {
defer close(requestDone)
client := &http.Client{Timeout: 3 * time.Second}
- response, err := client.Post(strings.TrimSuffix(target, "/client")+"/api/client-launcher/launch",
+ response, err := postLocalLauncher(client, strings.TrimSuffix(target, "/client")+"/api/client-launcher/launch",
"application/json", strings.NewReader(`{"mode":"link","language":"en"}`))
if err != nil {
if !cancelBeforeFailure {
@@ -260,6 +260,33 @@ func TestLauncherRetainsItsRuntimeFailureDuringCancellation(t *testing.T) {
}
}
resume()
+ if !cancelBeforeFailure {
+ select {
+ case <-requestDone:
+ case <-time.After(2 * time.Second):
+ t.Fatal("launcher did not report its failed room")
+ }
+ response, err := http.Get(strings.TrimSuffix(target, "/client") + "/api/client-launcher")
+ if err != nil {
+ t.Fatalf("room failure retired the App approval UI: %v", err)
+ }
+ var state struct {
+ Target, Detail string
+ Starting bool
+ }
+ err = json.NewDecoder(response.Body).Decode(&state)
+ response.Body.Close()
+ if err != nil || state.Target != "" || state.Starting || !strings.Contains(state.Detail, "local server port is unavailable") {
+ t.Fatalf("failed room was not reflected in the live launcher: %+v, %v", state, err)
+ }
+ select {
+ case err := <-done:
+ joined = true
+ t.Fatalf("room failure retired the resident App: %v", err)
+ default:
+ }
+ cancel()
+ }
select {
case err := <-done:
joined = true
@@ -304,7 +331,9 @@ func TestHungBrowserHandoffDoesNotBlockLauncherOrSiteExit(t *testing.T) {
done := make(chan error, 1)
go func() {
if mode == "site" {
- done <- runSite("https://share.example", options, control)
+ options.SiteSet = true
+ config.Site = "https://share.example"
+ done <- runLauncher(ctx, options, configPath, config, control, func(*Options, appconfig.Config) error { return nil })
} else {
done <- runLauncher(ctx, options, configPath, config, control, func(*Options, appconfig.Config) error { return nil })
}
@@ -369,7 +398,9 @@ func TestBrowserOpenFailureKeepsTheAppAvailable(t *testing.T) {
done := make(chan error, 1)
go func() {
if mode == "site" {
- done <- runSite("https://share.example", options, control)
+ options.SiteSet = true
+ config.Site = "https://share.example"
+ done <- runLauncher(ctx, options, configPath, config, control, func(*Options, appconfig.Config) error { return nil })
} else {
done <- runLauncher(ctx, options, configPath, config, control,
func(*Options, appconfig.Config) error { return nil })
@@ -404,20 +435,22 @@ func TestBrowserOpenFailureKeepsTheAppAvailable(t *testing.T) {
if mode == "launcher" {
client := &http.Client{Timeout: 3 * time.Second}
origin := strings.TrimSuffix(view.entry, "/client")
- response, err := client.Post(origin+"/api/client-launcher/launch", "application/json",
- strings.NewReader(`{"mode":"site","language":"en","site":"https://share.example"}`))
+
+ response, err := postLocalLauncher(client, origin+"/api/client-launcher/site", "application/json",
+ strings.NewReader(`{"site":"https://share.example","expectedSite":""}`))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
- var result struct{ Target string }
- if json.NewDecoder(response.Body).Decode(&result) != nil || response.StatusCode != http.StatusOK ||
- result.Target != launchURL("https://share.example") {
- t.Fatalf("manual launch failed: status=%d, target=%q", response.StatusCode, result.Target)
+ var result struct{ Site string }
+ if json.NewDecoder(response.Body).Decode(&result) != nil || response.StatusCode != http.StatusOK || result.Site != "https://share.example" {
+ t.Fatalf("manual approval failed: status=%d, site=%q", response.StatusCode, result.Site)
}
- if saved, err := launcher.LoadMode(configPath); err != nil || saved != launcher.ModeSite {
- t.Fatalf("manual selection was not saved: %q, %v", saved, err)
+ saved, err := appconfig.LoadOrCreate(configPath)
+ if err != nil || saved.Site != result.Site {
+ t.Fatalf("approval was not saved: %v", err)
}
+
}
})
}
@@ -699,8 +732,8 @@ func TestLinkModeKeepsOneLocalAuthority(t *testing.T) {
}
}
-// The Local room authority owns its ICE configuration: a STUN_URLS meant for
-// the App's own Pion edge must not reach it, and only --link is public.
+// The Local room authority owns its ICE configuration independently of Hosted
+// environment settings, and only --link is public.
func TestLocalServerOwnsItsSTUNConfiguration(t *testing.T) {
t.Setenv("STUN_URLS", "stun:inherited.example:3478")
for _, testCase := range []struct {
@@ -712,9 +745,9 @@ func TestLocalServerOwnsItsSTUNConfiguration(t *testing.T) {
{name: "local"},
{
name: "link", link: true,
- stunURLs: []string{publicSTUNURL},
+ stunURLs: []string{publicSTUNURL, publicFallbackSTUNURLA, publicFallbackSTUNURLB},
natPredictionSTUNURLs: []string{
- publicNATPredictionSTUNURLA, publicNATPredictionSTUNURLB,
+ publicFallbackSTUNURLA, publicFallbackSTUNURLB,
},
},
} {
@@ -743,3 +776,14 @@ func TestLocalServerOwnsItsSTUNConfiguration(t *testing.T) {
})
}
}
+
+func postLocalLauncher(client *http.Client, target, contentType string, body io.Reader) (*http.Response, error) {
+ req, err := http.NewRequest(http.MethodPost, target, body)
+ if err != nil {
+ return nil, err
+ }
+ address, _ := url.Parse(target)
+ req.Header.Set("Origin", address.Scheme+"://"+address.Host)
+ req.Header.Set("Content-Type", contentType)
+ return client.Do(req)
+}
diff --git a/internal/app/config/config.go b/internal/app/config/config.go
index 62583a33d..d6b096d26 100644
--- a/internal/app/config/config.go
+++ b/internal/app/config/config.go
@@ -72,7 +72,11 @@ func Save(path string, config Config) error {
if err != nil {
return err
}
- if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return saveFile(path, payload)
+}
+
+func saveFile(path string, payload []byte) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create App configuration directory: %w", err)
}
// Keep the last readable configuration until the replacement is complete.
diff --git a/internal/app/config/quality_preference.go b/internal/app/config/quality_preference.go
new file mode 100644
index 000000000..e289a0ca1
--- /dev/null
+++ b/internal/app/config/quality_preference.go
@@ -0,0 +1,76 @@
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "sync"
+
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+const MaxQualityPreferenceBytes = 1024
+
+// QualityPreference owns optional App-wide settings, not a live capture profile.
+// A separate file leaves the strict configuration readable by published Apps.
+type QualityPreference struct {
+ mu sync.Mutex
+ path string
+ value *protocol.QualitySettings
+}
+
+// A missing or unreadable preference must not prevent the App from starting.
+// Even on error the returned store can accept the user's next explicit choice.
+func LoadQualityPreference(configPath string) (*QualityPreference, error) {
+ p := &QualityPreference{path: configPath + ".quality"}
+ file, err := os.Open(p.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return p, nil
+ }
+ if err != nil {
+ return p, err
+ }
+ defer file.Close()
+ payload, err := io.ReadAll(io.LimitReader(file, MaxQualityPreferenceBytes+1))
+ if err != nil {
+ return p, err
+ }
+ var quality protocol.QualitySettings
+ if len(payload) > MaxQualityPreferenceBytes || json.Unmarshal(payload, &quality) != nil {
+ return p, errors.New("invalid saved sharing quality")
+ }
+ p.value = &quality
+ return p, nil
+}
+
+func (p *QualityPreference) Read() *protocol.QualitySettings {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.value == nil {
+ return nil
+ }
+ value := *p.value
+ return &value
+}
+
+func (p *QualityPreference) Save(quality protocol.QualitySettings) error {
+ payload, err := json.Marshal(quality)
+ if err != nil {
+ return err
+ }
+ // Reuse the wire validator instead of a second list of accepted settings.
+ if err := json.Unmarshal(payload, &quality); err != nil {
+ return err
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.value != nil && *p.value == quality {
+ return nil
+ }
+ if err := saveFile(p.path, append(payload, '\n')); err != nil {
+ return err
+ }
+ p.value = &quality
+ return nil
+}
diff --git a/internal/app/config/quality_preference_test.go b/internal/app/config/quality_preference_test.go
new file mode 100644
index 000000000..bab757deb
--- /dev/null
+++ b/internal/app/config/quality_preference_test.go
@@ -0,0 +1,74 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+func TestQualityPreferencePersistsWithoutChangingAppConfiguration(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "client.json")
+ if _, err := LoadOrCreate(path); err != nil {
+ t.Fatal(err)
+ }
+ original, _ := os.ReadFile(path)
+ p, err := LoadQualityPreference(path)
+ if err != nil || p.Read() != nil {
+ t.Fatalf("initial preference: %v, %v", p.Read(), err)
+ }
+ quality := protocol.DefaultQualitySettings
+ quality.Resolution, quality.MaxAudioBitrate = protocol.QualityResolution{Width: 1920, Height: 1200}, 320_000
+ if err := p.Save(quality); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := LoadQualityPreference(path)
+ if err != nil || loaded.Read() == nil || *loaded.Read() != quality {
+ t.Fatalf("restored: %v, %v", loaded.Read(), err)
+ }
+ copy := loaded.Read()
+ copy.Resolution.Width = 854
+ if *loaded.Read() != quality {
+ t.Fatal("caller modified stored settings")
+ }
+ current, _ := os.ReadFile(path)
+ if string(current) != string(original) {
+ t.Fatal("preference changed strict App configuration")
+ }
+ invalid := quality
+ invalid.Resolution.Width = 0x1_0000_0000
+ if p.Save(invalid) == nil || *p.Read() != quality {
+ t.Fatal("invalid choice changed preference")
+ }
+ if err := os.Remove(path + ".quality"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(path+".quality", 0700); err != nil {
+ t.Fatal(err)
+ }
+ quality.Resolution = protocol.QualityResolution{Width: 1280, Height: 720}
+ if p.Save(quality) == nil || p.Read().Resolution != (protocol.QualityResolution{Width: 1920, Height: 1200}) {
+ t.Fatal("failed write replaced saved preference")
+ }
+}
+
+func TestUnavailableQualityPreferenceCanAcceptTheNextChoice(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "client.json")
+ for _, payload := range []string{"null", "{}", "{", strings.Repeat(" ", MaxQualityPreferenceBytes+1)} {
+ if err := os.WriteFile(path+".quality", []byte(payload), 0600); err != nil {
+ t.Fatal(err)
+ }
+ p, err := LoadQualityPreference(path)
+ if err == nil || p == nil || p.Read() != nil {
+ t.Fatalf("invalid preference: %v, %v", p, err)
+ }
+ if err := p.Save(protocol.DefaultQualitySettings); err != nil {
+ t.Fatal(err)
+ }
+ if p.Read() == nil {
+ t.Fatal("choice was not saved")
+ }
+ }
+}
diff --git a/internal/app/console.go b/internal/app/console.go
index 0a4bb04da..bc3998a5f 100644
--- a/internal/app/console.go
+++ b/internal/app/console.go
@@ -87,7 +87,7 @@ var consoleCopy = map[string][3]string{
"local": {"Local network", "局域网", "□"},
"link": {"Public link", "公网链接", "↗"},
"site": {"Piik Site", "Piik 站点", "@"},
- "setup": {"Choose a mode in the browser", "在浏览器中选择模式", "?"},
+ "setup": {"App is ready", "App 已就绪", "?"},
"starting": {"Starting", "正在启动", "…"},
"ready": {"Ready", "已就绪", "✓"},
"stopping": {"Stopping", "正在退出", "→"},
diff --git a/internal/app/launcher/preference.go b/internal/app/launcher/preference.go
index facb77351..cf28634e8 100644
--- a/internal/app/launcher/preference.go
+++ b/internal/app/launcher/preference.go
@@ -31,5 +31,5 @@ func SaveMode(configPath string, mode Mode) error {
}
func validMode(mode Mode) bool {
- return mode == ModeLocal || mode == ModeLink || mode == ModeSite
+ return mode == ModeLocal || mode == ModeLink
}
diff --git a/internal/app/launcher/preference_test.go b/internal/app/launcher/preference_test.go
index b478395b7..60d4ff305 100644
--- a/internal/app/launcher/preference_test.go
+++ b/internal/app/launcher/preference_test.go
@@ -27,10 +27,8 @@ func TestSavedModeSurvivesRelaunchWithoutChangingLegacyConfig(t *testing.T) {
site string
want Mode
}{
- {ModeSite, config.Site, ModeSite},
{ModeLocal, config.Site, ModeLocal},
{ModeLink, config.Site, ModeLink},
- {ModeSite, "", ModeLink},
} {
if err := SaveMode(path, test.chosen); err != nil {
t.Fatal(err)
diff --git a/internal/app/launcher/server.go b/internal/app/launcher/server.go
index a63b0de45..028f8ed3f 100644
--- a/internal/app/launcher/server.go
+++ b/internal/app/launcher/server.go
@@ -8,6 +8,7 @@ import (
"io"
"io/fs"
"log/slog"
+ "mime"
"net"
"net/http"
"net/url"
@@ -34,13 +35,11 @@ type Mode string
const (
ModeLocal Mode = "local"
ModeLink Mode = "link"
- ModeSite Mode = "site"
)
type Selection struct {
Mode Mode `json:"mode"`
Language string `json:"language"`
- Site string `json:"site,omitempty"`
LocalAccessPassword string `json:"localAccessPassword"`
Debug bool `json:"debug,omitempty"`
LANAddress *string `json:"lanAddress,omitempty"`
@@ -54,6 +53,8 @@ type Options struct {
LocalAccessPassword string
Debug bool
LANAddress string
+ ChangeSite func(string) (string, error)
+ EnableDebug func() error
}
var listLANAddresses = lan.List
@@ -72,20 +73,20 @@ type Server struct {
revision string
debug bool
lanAddress string
+ changeSite func(string) (string, error)
+ enableDebug func() error
selection chan Selection
resultReady chan struct{}
- handled chan struct{}
done chan error
ready chan struct{}
- mu sync.Mutex
- selected bool
- resultSet bool
- target string
- resultErr error
- closeOnce sync.Once
- resultOnce sync.Once
- handledOnce sync.Once
+ mu sync.Mutex
+ selected bool
+ resultSet bool
+ target string
+ resultErr error
+ closeOnce sync.Once
+ resultOnce sync.Once
}
// Start serves the launcher page and its API on a loopback port. assets is the
@@ -123,9 +124,10 @@ func Start(parent context.Context, assets fs.FS, options Options) (*Server, erro
revision: strings.TrimSpace(options.Revision),
debug: options.Debug,
lanAddress: options.LANAddress,
+ changeSite: options.ChangeSite,
+ enableDebug: options.EnableDebug,
selection: make(chan Selection, 1),
resultReady: make(chan struct{}),
- handled: make(chan struct{}),
done: make(chan error, 1),
ready: make(chan struct{}),
}
@@ -151,26 +153,31 @@ func (server *Server) Selection() <-chan Selection {
return server.selection
}
-func (server *Server) Handled() <-chan struct{} {
- return server.handled
-}
-
func (server *Server) Done() <-chan error {
return server.done
}
+func (server *Server) Begin() bool {
+ server.mu.Lock()
+ defer server.mu.Unlock()
+ if server.selected {
+ return false
+ }
+ server.selected = true
+ return true
+}
+
func (server *Server) SetResult(target string, resultErr error) {
- server.resultOnce.Do(func() {
- if resultErr == nil && !validTarget(target) {
- resultErr = errors.New("App target is invalid")
- }
- server.mu.Lock()
- server.resultSet = true
- server.target = target
- server.resultErr = resultErr
- server.mu.Unlock()
- close(server.resultReady)
- })
+ if resultErr == nil && !validTarget(target) {
+ resultErr = errors.New("App target is invalid")
+ }
+ server.mu.Lock()
+ server.resultSet = true
+ server.target = target
+ server.resultErr = resultErr
+ server.mu.Unlock()
+ // Readiness completes once; a later room failure invalidates the old target.
+ server.resultOnce.Do(func() { close(server.resultReady) })
}
func (server *Server) Close() error {
@@ -196,11 +203,18 @@ func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Requ
return
}
response.Header().Set("X-Content-Type-Options", "nosniff")
+ response.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
+ response.Header().Set("X-Frame-Options", "DENY")
+ response.Header().Set("Referrer-Policy", "no-referrer")
switch request.URL.Path {
case "/api/client-launcher":
server.handleState(response, request)
case "/api/client-launcher/launch":
server.handleLaunch(response, request)
+ case "/api/client-launcher/site":
+ server.handleSite(response, request)
+ case "/api/client-launcher/debug":
+ server.handleDebug(response, request)
case "/client", "/client/":
if request.Method != http.MethodGet && request.Method != http.MethodHead {
response.Header().Set("Allow", "GET, HEAD")
@@ -232,24 +246,97 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
values = append(values, address.Address)
}
selected, _ := lan.Select(values, server.lanAddress)
- writeJSON(response, http.StatusOK, map[string]any{
+ server.mu.Lock()
+ state := map[string]any{
"site": server.site,
"localAccessPassword": server.localAccessPassword,
"version": server.version,
"revision": server.revision,
"packageTarget": runtime.GOOS + "-" + runtime.GOARCH,
"debug": server.debug,
+ "target": server.target,
+ "starting": server.selected && !server.resultSet,
+ "detail": errorDetail(server.resultErr),
"lan": map[string]any{"addresses": addresses, "selected": selected},
"defaultMode": func() Mode {
- if validMode(server.defaultMode) && (server.defaultMode != ModeSite || server.site != "") {
+ if server.defaultMode == ModeLocal || server.defaultMode == ModeLink {
return server.defaultMode
}
- if server.site != "" {
- return ModeSite
- }
return ModeLink
}(),
- })
+ }
+ server.mu.Unlock()
+ writeJSON(response, http.StatusOK, state)
+}
+
+func (server *Server) handleSite(response http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodPost {
+ response.Header().Set("Allow", http.MethodPost)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ request.Body = http.MaxBytesReader(response, request.Body, maxRequestBytes)
+ var change struct {
+ Site string `json:"site"`
+ ExpectedSite string `json:"expectedSite"`
+ }
+ decoder := json.NewDecoder(request.Body)
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&change) != nil || decoder.Decode(&struct{}{}) != io.EOF {
+ http.Error(response, "invalid site request", http.StatusBadRequest)
+ return
+ }
+ site, err := appconfig.NormalizeSite(change.Site)
+ if err != nil {
+ http.Error(response, "invalid site origin", http.StatusBadRequest)
+ return
+ }
+ server.mu.Lock()
+ if server.site != change.ExpectedSite {
+ current := server.site
+ server.mu.Unlock()
+ writeJSON(response, http.StatusConflict, map[string]string{"site": current})
+ return
+ }
+ if server.changeSite == nil {
+ server.mu.Unlock()
+ http.Error(response, "site approval is unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ current, err := server.changeSite(site)
+ // Revocation can succeed while media cleanup times out. Reflect the actual
+ // saved permission even on error; a stale confirmation must not restore it.
+ server.site = current
+ server.mu.Unlock()
+ if err != nil {
+ writeJSON(response, http.StatusServiceUnavailable, map[string]string{"site": current, "detail": errorDetail(err)})
+ return
+ }
+ writeJSON(response, http.StatusOK, map[string]string{"site": current})
+}
+
+func (server *Server) handleDebug(response http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodPost {
+ response.Header().Set("Allow", http.MethodPost)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ server.mu.Lock()
+ if !server.debug {
+ if server.selected || server.enableDebug == nil {
+ server.mu.Unlock()
+ http.Error(response, "enable diagnostics before starting a local room", http.StatusConflict)
+ return
+ }
+ if err := server.enableDebug(); err != nil {
+ server.mu.Unlock()
+ writeJSON(response, http.StatusServiceUnavailable, map[string]string{"detail": errorDetail(err)})
+ return
+ }
+ server.debug = true
+ }
+ server.mu.Unlock()
+ writeJSON(response, http.StatusOK, map[string]bool{"debug": true})
}
func (server *Server) handleLaunch(response http.ResponseWriter, request *http.Request) {
@@ -266,33 +353,21 @@ func (server *Server) handleLaunch(response http.ResponseWriter, request *http.R
})
return
}
- server.mu.Lock()
- if server.selected {
- server.mu.Unlock()
+ if !server.Begin() {
writeJSON(response, http.StatusConflict, map[string]string{
"error": "launch already requested", "detail": "Close and reopen Piik before starting again.",
})
return
}
- server.selected = true
- server.mu.Unlock()
- select {
- case server.selection <- selection:
- case <-request.Context().Done():
- server.markHandled()
- return
- case <-server.ctx.Done():
- server.markHandled()
- return
- }
+ // Begin commits this one selection to the App, independently of the page.
+ // The single-slot queue cannot block: only the accepted request writes it.
+ server.selection <- selection
select {
case <-server.resultReady:
case <-request.Context().Done():
- server.markHandled()
return
case <-server.ctx.Done():
- server.markHandled()
return
}
server.mu.Lock()
@@ -302,11 +377,9 @@ func (server *Server) handleLaunch(response http.ResponseWriter, request *http.R
writeJSON(response, http.StatusServiceUnavailable, map[string]string{
"error": "Piik App could not start", "detail": errorDetail(resultErr),
})
- server.markHandled()
return
}
writeJSON(response, http.StatusOK, map[string]string{"target": target})
- server.markHandled()
}
func errorDetail(err error) string {
@@ -321,10 +394,6 @@ func errorDetail(err error) string {
return string(detail)
}
-func (server *Server) markHandled() {
- server.handledOnce.Do(func() { close(server.handled) })
-}
-
func (server *Server) requestAllowed(request *http.Request) bool {
_, port, err := net.SplitHostPort(request.Host)
if err != nil {
@@ -339,6 +408,10 @@ func (server *Server) requestAllowed(request *http.Request) bool {
return false
}
origin := strings.TrimSpace(request.Header.Get("Origin"))
+ if request.Method == http.MethodPost {
+ mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
+ return err == nil && mediaType == "application/json" && sameOrigin(origin, "http://"+request.Host)
+ }
return origin == "" || sameOrigin(origin, "http://"+request.Host)
}
@@ -373,19 +446,6 @@ func decodeSelection(reader io.Reader) (Selection, error) {
}
switch selection.Mode {
case ModeLocal, ModeLink:
- if strings.TrimSpace(selection.Site) != "" {
- return Selection{}, errors.New("local launch cannot include a Site")
- }
- selection.Site = ""
- case ModeSite:
- if selection.LocalAccessPassword != "" {
- return Selection{}, errors.New("Site launch cannot include a local access password")
- }
- normalized, err := appconfig.NormalizeSite(selection.Site)
- if err != nil || normalized == "" {
- return Selection{}, errors.New("Site launch requires an HTTP or HTTPS origin")
- }
- selection.Site = normalized
default:
return Selection{}, errors.New("unknown launch mode")
}
diff --git a/internal/app/launcher/server_test.go b/internal/app/launcher/server_test.go
index 5ceeb5c75..96cb227ee 100644
--- a/internal/app/launcher/server_test.go
+++ b/internal/app/launcher/server_test.go
@@ -2,10 +2,14 @@ package launcher
import (
"bytes"
+ "context"
"encoding/json"
"errors"
+ "io"
"io/fs"
"net/http"
+ "net/http/httptest"
+ "net/url"
"runtime"
"strings"
"testing"
@@ -40,7 +44,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
Debug *bool `json:"debug"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
- state.Site != "https://share.example" || state.DefaultMode != ModeSite ||
+ state.Site != "https://share.example" || state.DefaultMode != ModeLink ||
state.Version != "development" || state.Revision != "" || state.LocalAccessPassword != "" || state.Debug == nil || *state.Debug {
t.Fatalf("launcher state = %d, %+v", response.StatusCode, state)
}
@@ -48,7 +52,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
result := make(chan *http.Response, 1)
requestErr := make(chan error, 1)
go func() {
- response, err := http.Post(
+ response, err := localPost(
origin+"/api/client-launcher/launch",
"application/json",
bytes.NewBufferString(`{"mode":"link","language":"vis","debug":true}`),
@@ -86,6 +90,64 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
}
}
+func TestRoomFailureInvalidatesReadyTargetButKeepsSiteApprovalAvailable(t *testing.T) {
+ server := startFixture(t, "https://share.example")
+ server.changeSite = func(site string) (string, error) { return site, nil }
+ origin := strings.TrimSuffix(server.URL(), "/client")
+ server.Begin()
+ server.SetResult("http://localhost:8787/#piik-client=1", nil)
+ server.SetResult("", errors.New("public invitation link stopped"))
+ response, err := http.Get(origin + "/api/client-launcher")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ var state struct {
+ Target, Detail, Site string
+ Starting bool
+ }
+ if json.NewDecoder(response.Body).Decode(&state) != nil || state.Target != "" || state.Starting ||
+ state.Detail != "public invitation link stopped" || state.Site != "https://share.example" {
+ t.Fatalf("failed room state = %+v", state)
+ }
+ response, err = localPost(origin+"/api/client-launcher/site", "application/json",
+ strings.NewReader(`{"site":"","expectedSite":"https://share.example"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("failed room blocked approval revocation: %d", response.StatusCode)
+ }
+}
+
+func TestLauncherRetainsAcceptedSelectionAfterRequestCancellation(t *testing.T) {
+ // Cancellation may race the handoff, but cannot discard an accepted launch.
+ for range 32 {
+ server := &Server{
+ ctx: t.Context(),
+ selection: make(chan Selection, 1),
+ resultReady: make(chan struct{}),
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/client-launcher/launch",
+ strings.NewReader(`{"mode":"link","language":"en"}`))
+ cancel()
+ server.handleLaunch(httptest.NewRecorder(), request)
+ select {
+ case selection := <-server.Selection():
+ if selection != (Selection{Mode: ModeLink, Language: "en"}) {
+ t.Fatalf("selection = %+v", selection)
+ }
+ default:
+ t.Fatal("cancelled Browser request left the App starting without a selection")
+ }
+ if server.Begin() {
+ t.Fatal("cancelled Browser request allowed a second launch")
+ }
+ }
+}
+
func TestLauncherIncludesTheInjectedBuildVersionAndRevision(t *testing.T) {
const version = "v1.2.3"
const revision = "0123456789abcdef0123456789abcdef01234567"
@@ -148,7 +210,7 @@ func TestLauncherLANChoicesUseCurrentAddressesAndTheExplicitPreference(t *testin
}
}
if err := json.NewDecoder(response.Body).Decode(&state); err != nil || response.StatusCode != http.StatusOK ||
- state.DefaultMode != ModeSite || state.LAN == nil || state.LAN.Addresses == nil ||
+ state.DefaultMode != ModeLink || state.LAN == nil || state.LAN.Addresses == nil ||
state.LAN.Selected != test.selected || len(state.LAN.Addresses) != len(test.addresses) {
t.Fatalf("LAN choices = %+v, status %d, %v", state, response.StatusCode, err)
}
@@ -164,7 +226,7 @@ func TestLauncherLANChoicesUseCurrentAddressesAndTheExplicitPreference(t *testin
func TestLauncherPassesTheChosenLANAddressToTheApp(t *testing.T) {
server := startFixture(t, "")
server.SetResult("http://localhost:8787/#piik-client=1", nil)
- response, err := http.Post(strings.TrimSuffix(server.URL(), "/client")+"/api/client-launcher/launch",
+ response, err := localPost(strings.TrimSuffix(server.URL(), "/client")+"/api/client-launcher/launch",
"application/json", strings.NewReader(`{"mode":"local","language":"en","lanAddress":"192.168.1.4"}`))
if err != nil {
t.Fatal(err)
@@ -206,7 +268,7 @@ func TestLauncherPreservesAnOptionalLocalPassword(t *testing.T) {
t.Fatal(err)
}
go func() {
- response, err := http.Post(
+ response, err := localPost(
origin+"/api/client-launcher/launch",
"application/json",
bytes.NewReader(payload),
@@ -251,25 +313,6 @@ func TestLauncherRequiresTheEmbeddedBrowserBuild(t *testing.T) {
}
}
-func TestLauncherNormalizesSiteSelection(t *testing.T) {
- for input, want := range map[string]string{
- " Share.Example:443/ ": "https://share.example",
- "https://Share.Example/": "https://share.example",
- "http://192.168.1.4:8787/": "http://192.168.1.4:8787",
- } {
- t.Run(input, func(t *testing.T) {
- payload, err := json.Marshal(Selection{Mode: ModeSite, Language: "en", Site: input})
- if err != nil {
- t.Fatal(err)
- }
- selection, err := decodeSelection(bytes.NewReader(payload))
- if err != nil || selection.Site != want {
- t.Fatalf("site selection = %+v, %v; want %q", selection, err, want)
- }
- })
- }
-}
-
func TestLauncherRejectsInvalidSelections(t *testing.T) {
for name, payload := range map[string]string{
"unknown": `{"mode":"other","language":"en"}`,
@@ -294,7 +337,7 @@ func TestLauncherRejectsInvalidSelections(t *testing.T) {
t.Run(name, func(t *testing.T) {
server := startFixture(t, "")
origin := strings.TrimSuffix(server.URL(), "/client")
- response, err := http.Post(
+ response, err := localPost(
origin+"/api/client-launcher/launch",
"application/json",
bytes.NewBufferString(payload),
@@ -331,7 +374,7 @@ func TestLauncherReportsSafeBoundedFailureDetails(t *testing.T) {
}
_ = response.Body.Close()
server.SetResult("", errors.New("public tunnel failed: token=private-secret "+strings.Repeat("network unavailable ", 200)))
- response, err = http.Post(origin+"/api/client-launcher/launch", "application/json",
+ response, err = localPost(origin+"/api/client-launcher/launch", "application/json",
strings.NewReader(`{"mode":"link","language":"en"}`))
if err != nil {
t.Fatal(err)
@@ -411,3 +454,14 @@ func startFixtureWithBuildAndPassword(
t.Cleanup(func() { _ = server.Close() })
return server
}
+
+func localPost(target, contentType string, body io.Reader) (*http.Response, error) {
+ req, err := http.NewRequest(http.MethodPost, target, body)
+ if err != nil {
+ return nil, err
+ }
+ address, _ := url.Parse(target)
+ req.Header.Set("Origin", address.Scheme+"://"+address.Host)
+ req.Header.Set("Content-Type", contentType)
+ return (&http.Client{Timeout: 3 * time.Second}).Do(req)
+}
diff --git a/internal/app/launcher/site_access_test.go b/internal/app/launcher/site_access_test.go
new file mode 100644
index 000000000..e8d95d837
--- /dev/null
+++ b/internal/app/launcher/site_access_test.go
@@ -0,0 +1,133 @@
+package launcher
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestSiteApprovalIsLocalExplicitAndDoesNotStartARoom(t *testing.T) {
+ current := ""
+ debugCalls := 0
+ server, err := Start(t.Context(), appAssets(), Options{
+ ChangeSite: func(site string) (string, error) { current = site; return current, nil },
+ EnableDebug: func() error { debugCalls++; return nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ origin := strings.TrimSuffix(server.URL(), "/client")
+ for _, path := range []string{"site", "launch", "debug"} {
+ for _, test := range []struct{ origin, contentType string }{
+ {"", "application/json"}, {"null", "application/json"}, {"https://other.example", "application/json"},
+ {origin, "text/plain"}, {origin, "application/x-www-form-urlencoded"},
+ } {
+ request, _ := http.NewRequest("POST", origin+"/api/client-launcher/"+path, strings.NewReader(`{"site":"https://other.example","expectedSite":""}`))
+ request.Header.Set("Origin", test.origin)
+ request.Header.Set("Content-Type", test.contentType)
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.StatusCode != 404 {
+ t.Fatalf("mutation admitted nonlocal or form request: %s %+v", path, test)
+ }
+ }
+ }
+ if current != "" || debugCalls != 0 {
+ t.Fatal("unapproved request had side effects")
+ }
+ for range 2 {
+ response, err := localPost(origin+"/api/client-launcher/debug", "application/json", strings.NewReader(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.StatusCode != 200 {
+ t.Fatal("standby diagnostics could not start")
+ }
+ }
+ if debugCalls != 1 {
+ t.Fatalf("diagnostic owner started %d times", debugCalls)
+ }
+ for _, test := range []struct {
+ site, expected, want string
+ status int
+ }{
+ {"https://Share.Example:443/", "", "https://share.example", 200},
+ {"https://other.example", "", "https://share.example", 409},
+ {"", "https://share.example", "", 200},
+ } {
+ body := fmt.Sprintf(`{"site":%q,"expectedSite":%q}`, test.site, test.expected)
+ response, err := localPost(origin+"/api/client-launcher/site", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var result struct{ Site string }
+ err = json.NewDecoder(response.Body).Decode(&result)
+ response.Body.Close()
+ if err != nil || response.StatusCode != test.status || result.Site != test.want || current != test.want {
+ t.Fatalf("approval result: status %d, %+v, saved %q, %v", response.StatusCode, result, current, err)
+ }
+ }
+ select {
+ case <-server.Selection():
+ t.Fatal("site approval created Local room authority")
+ default:
+ }
+ response, err := http.Get(server.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.Header.Get("X-Frame-Options") != "DENY" || response.Header.Get("Content-Security-Policy") != "frame-ancestors 'none'" {
+ t.Fatal("approval page can be framed")
+ }
+}
+
+func TestSiteApprovalFailureReflectsTheActualPermission(t *testing.T) {
+ for _, applied := range []bool{false, true} {
+ t.Run(fmt.Sprint(applied), func(t *testing.T) {
+ server, err := Start(t.Context(), appAssets(), Options{Site: "https://old.example", ChangeSite: func(site string) (string, error) {
+ if applied {
+ return site, errors.New("retirement still pending")
+ }
+ return "https://old.example", errors.New("save failed")
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ origin := strings.TrimSuffix(server.URL(), "/client")
+ response, err := localPost(origin+"/api/client-launcher/site", "application/json", strings.NewReader(`{"site":"https://new.example","expectedSite":"https://old.example"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failure struct{ Site, Detail string }
+ err = json.NewDecoder(response.Body).Decode(&failure)
+ response.Body.Close()
+ want := "https://old.example"
+ if applied {
+ want = "https://new.example"
+ }
+ if err != nil || response.StatusCode != 503 || failure.Site != want || failure.Detail == "" {
+ t.Fatal("error hid actual permission")
+ }
+ response, err = http.Get(origin + "/api/client-launcher")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state struct{ Site string }
+ err = json.NewDecoder(response.Body).Decode(&state)
+ response.Body.Close()
+ if err != nil || state.Site != want {
+ t.Fatal("launcher disagrees with persisted permission")
+ }
+ })
+ }
+}
diff --git a/internal/app/loopback/protocol.go b/internal/app/loopback/protocol.go
index ef7c0423d..8bd148095 100644
--- a/internal/app/loopback/protocol.go
+++ b/internal/app/loopback/protocol.go
@@ -9,9 +9,9 @@ import (
)
const (
- ProtocolVersion = 9
+ ProtocolVersion = 10
ServiceName = "piik-client"
- ControlSubprotocol = "piik-client-v9"
+ ControlSubprotocol = "piik-client-v10"
MaxControlMessageBytes = 256 << 10
DefaultPortStart = 39721
DefaultPortEnd = 39730
diff --git a/internal/app/loopback/protocol_test.go b/internal/app/loopback/protocol_test.go
index 5caf5ca00..9d354602a 100644
--- a/internal/app/loopback/protocol_test.go
+++ b/internal/app/loopback/protocol_test.go
@@ -7,8 +7,8 @@ import (
func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) {
valid := []string{
- `{"version":9,"id":"request_hello","type":"hello"}`,
- `{"version":9,"id":"request_ping","type":"ping"}`,
+ `{"version":10,"id":"request_hello","type":"hello"}`,
+ `{"version":10,"id":"request_ping","type":"ping"}`,
}
for _, payload := range valid {
if _, err := decodeRequest([]byte(payload)); err != nil {
@@ -17,10 +17,10 @@ func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) {
}
invalid := []string{
`{"version":3,"id":"request_ping","type":"ping"}`,
- `{"version":9,"id":"short","type":"ping"}`,
- `{"version":9,"id":"request_ping","type":"ping","extra":true}`,
- `{"version":9,"id":"request_hello","type":"hello","nonce":"obsolete"}`,
- `{"version":9,"id":"request_ping","type":"ping"} trailing`,
+ `{"version":10,"id":"short","type":"ping"}`,
+ `{"version":10,"id":"request_ping","type":"ping","extra":true}`,
+ `{"version":10,"id":"request_hello","type":"hello","nonce":"obsolete"}`,
+ `{"version":10,"id":"request_ping","type":"ping"} trailing`,
}
for _, payload := range invalid {
if _, err := decodeRequest([]byte(payload)); err == nil {
@@ -34,11 +34,11 @@ func TestDecodeRequestUsesTheSharedIdentifierBoundary(t *testing.T) {
if len(identifier) != 256 {
t.Fatalf("test identifier length = %d", len(identifier))
}
- payload := []byte(`{"version":9,"id":"` + identifier + `","type":"ping"}`)
+ payload := []byte(`{"version":10,"id":"` + identifier + `","type":"ping"}`)
if _, err := decodeRequest(payload); err != nil {
t.Fatalf("maximum identifier was rejected: %v", err)
}
- tooLong := []byte(`{"version":9,"id":"` + identifier + `a","type":"ping"}`)
+ tooLong := []byte(`{"version":10,"id":"` + identifier + `a","type":"ping"}`)
if _, err := decodeRequest(tooLong); err == nil {
t.Fatal("identifier beyond the boundary was accepted")
}
@@ -62,7 +62,7 @@ func TestValidateMessagesRequireTheExpectedPhase(t *testing.T) {
}
func TestEnvelopeAllowsAnExtensionToOwnItsStrictShape(t *testing.T) {
- payload := []byte(`{"version":9,"id":"request_extension","type":"extension","value":1}`)
+ payload := []byte(`{"version":10,"id":"request_extension","type":"extension","value":1}`)
message, err := decodeEnvelope(payload)
if err != nil || message.Type != "extension" {
t.Fatalf("extension envelope = %+v, %v", message, err)
diff --git a/internal/app/loopback/quality_preference.go b/internal/app/loopback/quality_preference.go
new file mode 100644
index 000000000..2f8527ff4
--- /dev/null
+++ b/internal/app/loopback/quality_preference.go
@@ -0,0 +1,53 @@
+package loopback
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+
+ appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+func (server *Server) handleQualityPreference(response http.ResponseWriter, request *http.Request) {
+ if server.qualityPreference == nil {
+ http.NotFound(response, request)
+ return
+ }
+ response.Header().Set("Cache-Control", "no-store")
+ if request.Method == http.MethodOptions {
+ response.Header().Set("Access-Control-Allow-Methods", "GET, PUT")
+ response.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Piik-Instance")
+ allowPrivateNetwork(response, request)
+ response.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if request.Method != http.MethodGet && request.Method != http.MethodPut {
+ response.Header().Set("Allow", "GET, PUT, OPTIONS")
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ // Origin/Host are checked by ServeHTTP. Bind reads and writes to this App run.
+ if request.Header.Get("X-Piik-Instance") != server.endpoint.InstanceToken {
+ http.Error(response, "invalid App instance", http.StatusForbidden)
+ return
+ }
+ if request.Method == http.MethodGet {
+ writeJSON(response, http.StatusOK, server.qualityPreference.Read())
+ return
+ }
+ request.Body = http.MaxBytesReader(response, request.Body, appconfig.MaxQualityPreferenceBytes)
+ decoder := json.NewDecoder(request.Body)
+ var quality protocol.QualitySettings
+ if decoder.Decode(&quality) != nil || decoder.Decode(&struct{}{}) != io.EOF {
+ http.Error(response, "invalid sharing quality", http.StatusBadRequest)
+ return
+ }
+ if err := server.qualityPreference.Save(quality); err != nil {
+ slog.Debug("piik-client", "event", "quality-preference-save-failed", "error", err)
+ http.Error(response, "sharing preference could not be saved", http.StatusInternalServerError)
+ return
+ }
+ response.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/app/loopback/quality_preference_test.go b/internal/app/loopback/quality_preference_test.go
new file mode 100644
index 000000000..4e59b95e3
--- /dev/null
+++ b/internal/app/loopback/quality_preference_test.go
@@ -0,0 +1,78 @@
+package loopback
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+func TestQualityPreferenceRequiresCurrentOriginAndInstanceWithoutClaimingControl(t *testing.T) {
+ p, err := appconfig.LoadQualityPreference(filepath.Join(t.TempDir(), "client.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := startTestServerWithOptions(t, Options{AllowedOrigins: []string{testOrigin}, QualityPreference: p})
+ endpoint := server.Endpoint()
+ response := doHealthRequest(t, endpoint, endpoint.Host, testOrigin)
+ var health Health
+ if err := json.NewDecoder(response.Body).Decode(&health); err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if !health.QualityPreference {
+ t.Fatal("optional preference was not advertised")
+ }
+ payload, _ := json.Marshal(protocol.DefaultQualitySettings)
+ for _, input := range []struct {
+ method, origin, token, body string
+ status int
+ }{
+ {"OPTIONS", testOrigin, "", "", http.StatusNoContent},
+ {"OPTIONS", "https://other.example", "", "", http.StatusForbidden},
+ {"GET", testOrigin, "", "", http.StatusForbidden},
+ {"PUT", testOrigin, "old-instance", string(payload), http.StatusForbidden},
+ {"PUT", "https://other.example", endpoint.InstanceToken, string(payload), http.StatusForbidden},
+ {"PUT", testOrigin, endpoint.InstanceToken, `{}`, http.StatusBadRequest},
+ {"PUT", testOrigin, endpoint.InstanceToken, `null`, http.StatusBadRequest},
+ {"PUT", testOrigin, endpoint.InstanceToken, string(payload) + `{}`, http.StatusBadRequest},
+ {"PUT", testOrigin, endpoint.InstanceToken, strings.Repeat(" ", 1025) + string(payload), http.StatusBadRequest},
+ {"POST", testOrigin, endpoint.InstanceToken, string(payload), http.StatusMethodNotAllowed},
+ {"PUT", testOrigin, endpoint.InstanceToken, string(payload), http.StatusNoContent},
+ {"GET", testOrigin, endpoint.InstanceToken, "", http.StatusOK},
+ } {
+ request, _ := http.NewRequest(input.method, endpoint.URL+"/quality-preference", strings.NewReader(input.body))
+ request.Header.Set("Origin", input.origin)
+ request.Header.Set("X-Piik-Instance", input.token)
+ request.Header.Set("Access-Control-Request-Private-Network", "true")
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(response.Body)
+ response.Body.Close()
+ if response.StatusCode != input.status {
+ t.Fatalf("%s: status %d, want %d", input.method, response.StatusCode, input.status)
+ }
+ if input.status == http.StatusOK {
+ var quality protocol.QualitySettings
+ if err := json.Unmarshal(body, &quality); err != nil || quality != protocol.DefaultQualitySettings {
+ t.Fatalf("saved quality: %v, %v", quality, err)
+ }
+ }
+ if input.method == "OPTIONS" && input.status == http.StatusNoContent &&
+ (response.Header.Get("Access-Control-Allow-Methods") != "GET, PUT" || response.Header.Get("Access-Control-Allow-Private-Network") != "true") {
+ t.Fatal("invalid preflight")
+ }
+ }
+ server.mu.Lock()
+ defer server.mu.Unlock()
+ if len(server.connections) != 0 {
+ t.Fatal("preference claimed media control")
+ }
+}
diff --git a/internal/app/loopback/server.go b/internal/app/loopback/server.go
index a2f4e66e8..20f48e0f1 100644
--- a/internal/app/loopback/server.go
+++ b/internal/app/loopback/server.go
@@ -16,6 +16,8 @@ import (
"sync"
"time"
+ appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
"github.com/coder/websocket"
)
@@ -29,12 +31,13 @@ const (
)
type Options struct {
- PortStart int
- PortEnd int
- AllowedOrigins []string
- NativeMedia NativeMediaCapabilities
- NewControl func() ControlSession
- Presentation func(language string)
+ PortStart int
+ PortEnd int
+ AllowedOrigins []string
+ NativeMedia NativeMediaCapabilities
+ NewControl func() ControlSession
+ Presentation func(language string)
+ QualityPreference *appconfig.QualityPreference
}
type ControlSession interface {
@@ -51,6 +54,9 @@ type ControlSession interface {
type ControlEvent struct {
Value any
Current func() bool
+ // BeforeWrite releases an operation's admission before its ACK can trigger
+ // the next command. It must not block on I/O or send another event.
+ BeforeWrite func()
}
type Endpoint struct {
@@ -61,46 +67,64 @@ type Endpoint struct {
}
type Health struct {
- Protocol int `json:"protocol"`
- Service string `json:"service"`
- Port int `json:"port"`
- InstanceToken string `json:"instanceToken"`
- NativeMedia NativeMediaCapabilities `json:"nativeMedia"`
+ Protocol int `json:"protocol"`
+ Service string `json:"service"`
+ Port int `json:"port"`
+ InstanceToken string `json:"instanceToken"`
+ NativeMedia NativeMediaCapabilities `json:"nativeMedia"`
+ QualityPreference bool `json:"qualityPreference,omitempty"`
}
type NativeMediaCapabilities struct {
- ReceiverReuse bool `json:"receiverReuse,omitempty"`
- Video bool `json:"video"`
- CaptureBorderControl bool `json:"captureBorderControl,omitempty"`
- Microphone bool `json:"microphone,omitempty"`
- ProcessAudio bool `json:"processAudio"`
- ProcessAudioExclusion bool `json:"processAudioExclusion,omitempty"`
- SystemAudio bool `json:"systemAudio"`
- HardwareH264 bool `json:"hardwareH264"`
- SoftwareVP8 bool `json:"softwareVP8"`
+ MaxVideoResolution protocol.QualityResolution `json:"maxVideoResolution,omitzero"`
+ MaxVideoPixels int `json:"maxVideoPixels,omitempty"`
+ MaxVideoMacroblocksPerSecond int `json:"maxVideoMacroblocksPerSecond,omitempty"`
+ MaxVideoFramerate int `json:"maxVideoFramerate,omitempty"`
+ MaxVideoBitrate int `json:"maxVideoBitrate,omitempty"`
+ MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
+ ReceiverReuse bool `json:"receiverReuse,omitempty"`
+ Video bool `json:"video"`
+ CaptureBorderControl bool `json:"captureBorderControl,omitempty"`
+ Microphone bool `json:"microphone,omitempty"`
+ ProcessAudio bool `json:"processAudio"`
+ ProcessAudioExclusion bool `json:"processAudioExclusion,omitempty"`
+ SystemAudio bool `json:"systemAudio"`
+ HardwareH264 bool `json:"hardwareH264"`
+ SoftwareVP8 bool `json:"softwareVP8"`
}
type Server struct {
- ctx context.Context
- cancel context.CancelFunc
- listener net.Listener
- httpServer *http.Server
- endpoint Endpoint
- allowedOrigins []string
- nativeMedia NativeMediaCapabilities
- newControl func() ControlSession
- presentation func(string)
- done chan error
+ ctx context.Context
+ cancel context.CancelFunc
+ listener net.Listener
+ httpServer *http.Server
+ endpoint Endpoint
+ allowedOrigins []string
+ nativeMedia NativeMediaCapabilities
+ newControl func() ControlSession
+ presentation func(string)
+ qualityPreference *appconfig.QualityPreference
+ connectURL string
+ done chan error
mu sync.Mutex
- connections map[*websocket.Conn]struct{}
- claimed int
+ connections map[*controlConnection]struct{}
closed bool
controls sync.WaitGroup
closeOnce sync.Once
closeErr error
}
+// Claims start before the WebSocket upgrade so revocation also retires a
+// pending handshake, even if that same origin is approved again later.
+type controlConnection struct {
+ origin string
+ ctx context.Context
+ cancel context.CancelFunc
+ socket *websocket.Conn
+ done chan struct{}
+}
+
func Start(parent context.Context, options Options) (*Server, error) {
if parent == nil {
parent = context.Background()
@@ -129,12 +153,13 @@ func Start(parent context.Context, options Options) (*Server, error) {
Port: address.Port,
InstanceToken: instanceToken,
},
- allowedOrigins: normalizedOrigins(options.AllowedOrigins),
- nativeMedia: options.NativeMedia,
- newControl: options.NewControl,
- presentation: options.Presentation,
- done: make(chan error, 1),
- connections: make(map[*websocket.Conn]struct{}),
+ allowedOrigins: normalizedOrigins(options.AllowedOrigins),
+ nativeMedia: options.NativeMedia,
+ newControl: options.NewControl,
+ presentation: options.Presentation,
+ qualityPreference: options.QualityPreference,
+ done: make(chan error, 1),
+ connections: make(map[*controlConnection]struct{}),
}
server.httpServer = &http.Server{
Handler: server,
@@ -157,9 +182,40 @@ func (server *Server) Done() <-chan error {
return server.done
}
-func (server *Server) SetAllowedOrigins(origins []string) {
+func (server *Server) SetAllowedOrigins(origins []string) error {
server.mu.Lock()
server.allowedOrigins = normalizedOrigins(origins)
+ var retired []*controlConnection
+ for connection := range server.connections {
+ if connection.ctx.Err() != nil || !server.originAllowedLocked(connection.origin) {
+ connection.cancel()
+ retired = append(retired, connection)
+ }
+ }
+ server.mu.Unlock()
+ ctx, cancel := context.WithTimeout(server.ctx, shutdownTimeout)
+ defer cancel()
+ for _, connection := range retired {
+ // A cancelled claim cannot attach a socket after leaving the lock.
+ if connection.socket != nil {
+ _ = connection.socket.CloseNow()
+ }
+ }
+ for _, connection := range retired {
+ select {
+ case <-connection.done:
+ case <-ctx.Done():
+ // Permission already changed. Never restore a revoked origin because
+ // one of its media resources is slow to close.
+ return errors.New("App is still releasing the previous site's media; try again shortly")
+ }
+ }
+ return nil
+}
+
+func (server *Server) SetConnectURL(value string) {
+ server.mu.Lock()
+ server.connectURL = value
server.mu.Unlock()
}
@@ -169,7 +225,10 @@ func (server *Server) Close() error {
server.closed = true
connections := make([]*websocket.Conn, 0, len(server.connections))
for connection := range server.connections {
- connections = append(connections, connection)
+ connection.cancel()
+ if connection.socket != nil {
+ connections = append(connections, connection.socket)
+ }
}
server.mu.Unlock()
server.cancel()
@@ -206,6 +265,10 @@ func (server *Server) serve() {
}
func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) {
+ if request.URL.Path == "/connect-discovery" {
+ server.handleConnectDiscovery(response, request)
+ return
+ }
if !server.requestAllowed(response, request) {
return
}
@@ -216,11 +279,55 @@ func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Requ
server.handleControl(response, request)
case "/presentation":
server.handlePresentation(response, request)
+ case "/quality-preference":
+ server.handleQualityPreference(response, request)
default:
http.NotFound(response, request)
}
}
+// Discovery is the only cross-origin endpoint available before local approval.
+// It exposes process identity and its local approval page, never capabilities,
+// source lists, settings, or control access. A GET has no side effects.
+func (server *Server) handleConnectDiscovery(response http.ResponseWriter, request *http.Request) {
+ if !server.hostAllowed(request.Host) {
+ http.NotFound(response, request)
+ return
+ }
+ origin := request.Header.Get("Origin")
+ normalized, err := appconfig.NormalizeSite(origin)
+ if err != nil || origin == "" || normalized != origin {
+ http.Error(response, "forbidden", http.StatusForbidden)
+ return
+ }
+ response.Header().Set("Access-Control-Allow-Origin", origin)
+ response.Header().Add("Vary", "Origin")
+ response.Header().Set("Cache-Control", "no-store")
+ if request.Method == http.MethodOptions {
+ response.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
+ allowPrivateNetwork(response, request)
+ response.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if request.Method != http.MethodGet {
+ response.Header().Set("Allow", "GET, OPTIONS")
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ server.mu.Lock()
+ connectURL := server.connectURL
+ server.mu.Unlock()
+ if connectURL == "" {
+ http.NotFound(response, request)
+ return
+ }
+ writeJSON(response, http.StatusOK, map[string]any{
+ "protocol": ProtocolVersion, "service": ServiceName,
+ "port": server.endpoint.Port, "instanceToken": server.endpoint.InstanceToken,
+ "connectURL": connectURL,
+ })
+}
+
func (server *Server) handlePresentation(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodOptions {
response.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
@@ -265,11 +372,12 @@ func (server *Server) handleHealth(response http.ResponseWriter, request *http.R
}
response.Header().Set("Cache-Control", "no-store")
writeJSON(response, http.StatusOK, Health{
- Protocol: ProtocolVersion,
- Service: ServiceName,
- Port: server.endpoint.Port,
- InstanceToken: server.endpoint.InstanceToken,
- NativeMedia: server.nativeMedia,
+ Protocol: ProtocolVersion,
+ Service: ServiceName,
+ Port: server.endpoint.Port,
+ InstanceToken: server.endpoint.InstanceToken,
+ NativeMedia: server.nativeMedia,
+ QualityPreference: server.qualityPreference != nil,
})
}
@@ -285,15 +393,16 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !server.claimConnection() {
+ claim := server.claimConnection(request.Header.Get("Origin"))
+ if claim == nil {
http.Error(response, "native control capacity is occupied", http.StatusConflict)
return
}
var connection *websocket.Conn
- defer func() { server.releaseConnection(connection) }()
+ defer server.releaseConnection(claim)
expectedSubprotocol := ControlSubprotocol + "." + server.endpoint.InstanceToken
var err error
- connection, err = websocket.Accept(response, request, &websocket.AcceptOptions{
+ connection, err = websocket.Accept(response, request.WithContext(claim.ctx), &websocket.AcceptOptions{
Subprotocols: []string{expectedSubprotocol},
OriginPatterns: server.originPatterns(),
})
@@ -305,15 +414,18 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
closeControl(connection, "unsupported control protocol")
return
}
- if !server.setConnection(connection) {
+ if !server.setConnection(claim, connection) {
return
}
connection.SetReadLimit(MaxControlMessageBytes)
- helloContext, cancelHello := context.WithTimeout(server.ctx, helloTimeout)
+ helloContext, cancelHello := context.WithTimeout(claim.ctx, helloTimeout)
messageType, payload, err := connection.Read(helloContext)
cancelHello()
- if err != nil || messageType != websocket.MessageText {
+ if err != nil {
+ return
+ }
+ if messageType != websocket.MessageText {
closeControl(connection, "hello frame required")
return
}
@@ -334,6 +446,9 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
if event.Current != nil && !event.Current() {
return nil
}
+ if event.BeforeWrite != nil {
+ event.BeforeWrite()
+ }
value = event.Value
}
encoded, encodeErr := encodeMessage(value)
@@ -342,13 +457,12 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
}
return connection.Write(ctx, websocket.MessageText, encoded)
}
- if err = write(server.ctx, controlMessage{
+ if err = write(claim.ctx, controlMessage{
Version: ProtocolVersion, ID: message.ID, Type: "ready",
}); err != nil {
return
}
- controlContext, cancelControl := context.WithCancel(server.ctx)
- defer cancelControl()
+ controlContext, cancelControl := claim.ctx, claim.cancel
var extension ControlSession
if server.newControl != nil {
extension = server.newControl()
@@ -388,12 +502,18 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
}
}()
for {
+ if controlContext.Err() != nil {
+ return
+ }
select {
case <-controlContext.Done():
return
case payload = <-reads:
messageType = websocket.MessageText
}
+ if controlContext.Err() != nil {
+ return
+ }
message, err = decodeEnvelope(payload)
if err != nil {
closeControl(connection, "invalid control message")
@@ -401,13 +521,15 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
}
if message.Type == "ping" {
strict, strictErr := decodeRequest(payload)
- if strictErr != nil || validatePing(strict) != nil ||
- write(controlContext, controlMessage{
- Version: ProtocolVersion, ID: message.ID, Type: "pong",
- }) != nil {
+ if strictErr != nil || validatePing(strict) != nil {
closeControl(connection, "invalid control message")
return
}
+ if write(controlContext, controlMessage{
+ Version: ProtocolVersion, ID: message.ID, Type: "pong",
+ }) != nil {
+ return
+ }
continue
}
if extension == nil {
@@ -475,6 +597,20 @@ func (server *Server) allowedOrigin(origin string) bool {
return false
}
+// Caller holds mu. Empty Origin is reserved for per-user native tooling;
+// browsers always send it when opening a cross-origin control connection.
+func (server *Server) originAllowedLocked(origin string) bool {
+ if origin == "" || server.localOrigin(origin) {
+ return true
+ }
+ for _, allowed := range server.allowedOrigins {
+ if sameOrigin(origin, allowed) {
+ return true
+ }
+ }
+ return false
+}
+
func (server *Server) hostAllowed(host string) bool {
if host == server.endpoint.Host {
return true
@@ -483,40 +619,40 @@ func (server *Server) hostAllowed(host string) bool {
}
func (server *Server) localOrigin(origin string) bool {
- parsed, err := url.Parse(origin)
- return err == nil && parsed.Scheme == "http" &&
- parsed.Port() == strconv.Itoa(server.endpoint.Port) &&
- (strings.EqualFold(parsed.Hostname(), "localhost") ||
- strings.EqualFold(parsed.Hostname(), "127.0.0.1"))
+ port := strconv.Itoa(server.endpoint.Port)
+ return sameOrigin(origin, "http://127.0.0.1:"+port) || sameOrigin(origin, "http://localhost:"+port)
}
-func (server *Server) claimConnection() bool {
+func (server *Server) claimConnection(origin string) *controlConnection {
server.mu.Lock()
defer server.mu.Unlock()
- if server.closed || server.claimed >= maxControlSessions {
- return false
+ if server.closed || len(server.connections) >= maxControlSessions || !server.originAllowedLocked(origin) {
+ return nil
}
- server.claimed++
+ ctx, cancel := context.WithCancel(server.ctx)
+ claim := &controlConnection{origin: origin, ctx: ctx, cancel: cancel, done: make(chan struct{})}
+ server.connections[claim] = struct{}{}
server.controls.Add(1)
- return true
+ return claim
}
-func (server *Server) setConnection(connection *websocket.Conn) bool {
+func (server *Server) setConnection(claim *controlConnection, connection *websocket.Conn) bool {
server.mu.Lock()
defer server.mu.Unlock()
- if server.closed {
+ if server.closed || claim.ctx.Err() != nil {
return false
}
- server.connections[connection] = struct{}{}
+ claim.socket = connection
return true
}
-func (server *Server) releaseConnection(connection *websocket.Conn) {
+func (server *Server) releaseConnection(connection *controlConnection) {
+ connection.cancel()
server.mu.Lock()
delete(server.connections, connection)
- server.claimed--
server.mu.Unlock()
server.controls.Done()
+ close(connection.done)
}
func listenInRange(bindAddress string, start, end int) (net.Listener, error) {
diff --git a/internal/app/loopback/server_test.go b/internal/app/loopback/server_test.go
index 198b26567..37e0c60f5 100644
--- a/internal/app/loopback/server_test.go
+++ b/internal/app/loopback/server_test.go
@@ -251,15 +251,22 @@ func TestControlSessionSharesOneBoundedSocketForResponsesAndEvents(t *testing.T)
Current: func() bool { return current }}
current = false
extension.events <- retired
+ prepared := make(chan struct{})
extension.events <- ControlEvent{
- Value: controlMessage{Version: ProtocolVersion, Type: "current-event"},
- Current: func() bool { return true },
+ Value: controlMessage{Version: ProtocolVersion, Type: "current-event"},
+ Current: func() bool { return true },
+ BeforeWrite: func() { close(prepared) },
}
var currentEvent controlMessage
readControl(t, connection, ¤tEvent)
if currentEvent.Type != "current-event" {
t.Fatalf("retired event escaped the delivery fence: %+v", currentEvent)
}
+ select {
+ case <-prepared:
+ default:
+ t.Fatal("response was visible before its operation released admission")
+ }
extension.events <- controlMessage{
Version: ProtocolVersion, ID: "event_123456", Type: "extension-event",
}
diff --git a/internal/app/loopback/site_access_test.go b/internal/app/loopback/site_access_test.go
new file mode 100644
index 000000000..3496896e4
--- /dev/null
+++ b/internal/app/loopback/site_access_test.go
@@ -0,0 +1,150 @@
+package loopback
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestConnectDiscoveryDoesNotGrantNativeAccess(t *testing.T) {
+ server := startTestServer(t, "")
+ server.SetConnectURL("http://127.0.0.1:18990/client")
+ endpoint := server.Endpoint()
+ for _, test := range []struct {
+ method, path, origin string
+ status int
+ }{
+ {"GET", "/connect-discovery", testOrigin, 200},
+ {"OPTIONS", "/connect-discovery", testOrigin, 204},
+ {"POST", "/connect-discovery", testOrigin, 405},
+ {"GET", "/connect-discovery", "null", 403},
+ {"GET", "/connect-discovery", "", 403},
+ {"GET", "/connect-discovery", testOrigin + "/path", 403},
+ {"GET", "/health", testOrigin, 403},
+ {"GET", "/control", testOrigin, 403},
+ {"GET", "/quality-preference", testOrigin, 403},
+ } {
+ request, _ := http.NewRequest(test.method, endpoint.URL+test.path, nil)
+ request.Header.Set("Origin", test.origin)
+ request.Header.Set("Access-Control-Request-Private-Network", "true")
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if response.StatusCode != test.status {
+ t.Fatalf("%+v: status %d", test, response.StatusCode)
+ }
+ if test.status == 200 {
+ var body map[string]any
+ if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body) != 5 || body["connectURL"] != "http://127.0.0.1:18990/client" ||
+ body["instanceToken"] != endpoint.InstanceToken || body["service"] != ServiceName {
+ t.Fatalf("discovery exposed more than process identity and approval address: %v", body)
+ }
+ }
+ response.Body.Close()
+ }
+}
+
+type retiringControl struct {
+ *testControlSession
+ finish <-chan struct{}
+}
+
+func (session *retiringControl) Close() error {
+ _ = session.testControlSession.Close()
+ <-session.finish
+ return nil
+}
+
+func TestSiteChangeWaitsForMediaRetirementWithoutStoppingLocalSession(t *testing.T) {
+ finish := make(chan struct{})
+ defer close(finish)
+ created := make(chan *testControlSession, 2)
+ server := startTestServerWithOptions(t, Options{AllowedOrigins: []string{testOrigin}, NewControl: func() ControlSession {
+ session := &testControlSession{events: make(chan any), closed: make(chan struct{})}
+ created <- session
+ return &retiringControl{session, finish}
+ }})
+ endpoint := server.Endpoint()
+ old := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin)
+ defer old.CloseNow()
+ writeControl(t, old, requestJSON("old_hello", "hello"))
+ var ready controlMessage
+ readControl(t, old, &ready)
+ oldSession := <-created
+ local := dialControl(t, endpoint, endpoint.InstanceToken, endpoint.URL)
+ defer local.CloseNow()
+ writeControl(t, local, requestJSON("local_hello", "hello"))
+ readControl(t, local, &ready)
+ localSession := <-created
+
+ done := make(chan error, 1)
+ go func() { done <- server.SetAllowedOrigins([]string{"https://next.example"}) }()
+ select {
+ case <-oldSession.closed:
+ case <-time.After(time.Second):
+ t.Fatal("revoked session did not start retirement")
+ }
+ select {
+ case err := <-done:
+ t.Fatalf("permission reported ready before cleanup: %v", err)
+ default:
+ }
+ select {
+ case <-localSession.closed:
+ t.Fatal("unrelated Local session was closed")
+ default:
+ }
+ writeControl(t, local, requestJSON("local_ping", "ping"))
+ readControl(t, local, &ready)
+ if ready.Type != "pong" {
+ t.Fatal("Local session was interrupted")
+ }
+ // A stuck media owner must not hang consent forever or restore old permission.
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("stuck cleanup was reported as successful")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("permission retirement was unbounded")
+ }
+ response := doHealthRequest(t, endpoint, endpoint.Host, testOrigin)
+ response.Body.Close()
+ if response.StatusCode != 403 {
+ t.Fatal("timeout restored the revoked origin")
+ }
+}
+
+func TestPendingHandshakeCannotSurviveOriginReplacement(t *testing.T) {
+ server := startTestServer(t, testOrigin)
+ claim := server.claimConnection(testOrigin)
+ if claim == nil {
+ t.Fatal("initial claim failed")
+ }
+ defer server.releaseConnection(claim)
+ done := make(chan error, 1)
+ go func() { done <- server.SetAllowedOrigins(nil) }()
+ select {
+ case <-claim.ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("pending upgrade was not cancelled")
+ }
+ // Regrant the same origin before its old handshake returns.
+ server.mu.Lock()
+ server.allowedOrigins = []string{testOrigin}
+ server.mu.Unlock()
+ if server.setConnection(claim, nil) {
+ t.Fatal("old generation regained permission")
+ }
+ // Cancellation of the App also bounds an already pending permission change.
+ server.cancel()
+ if err := <-done; err == nil || claim.ctx.Err() != context.Canceled {
+ t.Fatal("cancelled change reported ready")
+ }
+}
diff --git a/internal/app/mediaedge/audio.go b/internal/app/mediaedge/audio.go
index d658dfbfe..a3b840507 100644
--- a/internal/app/mediaedge/audio.go
+++ b/internal/app/mediaedge/audio.go
@@ -147,19 +147,7 @@ func (source *AudioSource) SetBitrate(bitrate int) error {
if closed {
return errors.New("native audio source is closed")
}
- if err := source.encoder.SetBitrate(bitrate); err != nil {
- return err
- }
- source.mu.Lock()
- edges := make([]*Edge, 0, len(source.edges))
- for edge := range source.edges {
- edges = append(edges, edge)
- }
- source.mu.Unlock()
- for _, edge := range edges {
- edge.transport.SetAudioBitrate(uint32(bitrate))
- }
- return nil
+ return source.encoder.SetBitrate(bitrate)
}
func (source *AudioSource) configuredBitrate() uint32 {
diff --git a/internal/app/mediaedge/candidate_gathering.go b/internal/app/mediaedge/candidate_gathering.go
index ef94a6fc6..fbce646db 100644
--- a/internal/app/mediaedge/candidate_gathering.go
+++ b/internal/app/mediaedge/candidate_gathering.go
@@ -117,7 +117,6 @@ func (gathering *localCandidateGathering) start() {
}
})
}()
- index := 0
var observed []mappedAddress
for survey != nil || mapping != nil {
select {
@@ -135,11 +134,10 @@ func (gathering *localCandidateGathering) start() {
survey = nil
continue
}
- index++
mid := "0"
line := uint16(0)
gathering.emitCandidate(&webrtc.ICECandidateInit{
- Candidate: "candidate:ns" + strconv.Itoa(index) +
+ Candidate: "candidate:ns" + strconv.Itoa(mapped.serverIndex) +
" 1 udp " + strconv.Itoa(surveyCandidatePriority) + " " +
mapped.address + " " +
strconv.Itoa(mapped.port) + " typ srflx raddr 0.0.0.0 rport " +
diff --git a/internal/app/mediaedge/candidate_gathering_test.go b/internal/app/mediaedge/candidate_gathering_test.go
index 694fd2a0d..f7a2d9ac2 100644
--- a/internal/app/mediaedge/candidate_gathering_test.go
+++ b/internal/app/mediaedge/candidate_gathering_test.go
@@ -3,11 +3,14 @@ package mediaedge
import (
"context"
"net"
+ "strconv"
"strings"
+ "sync/atomic"
"testing"
"time"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/pion/stun/v4"
"github.com/pion/turn/v5"
"github.com/pion/webrtc/v4"
)
@@ -135,6 +138,9 @@ func TestSurveyHealthySTUNDoesNotWaitForSlowDNS(t *testing.T) {
if mapped.port != socket.localPort {
t.Fatal("STUN did not use the shared media socket")
}
+ if mapped.serverIndex != 2 {
+ t.Fatal("failed DNS shifted the responding destination's identity")
+ }
case <-time.After(time.Second):
t.Fatal("healthy STUN waited for the broken DNS target")
}
@@ -148,6 +154,100 @@ func TestSurveyHealthySTUNDoesNotWaitForSlowDNS(t *testing.T) {
}
}
+func TestSurveyCandidateIdentitySurvivesResponseOrder(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ socket := testICESocket(t, engine)
+ var releaseSlow atomic.Bool
+ slow := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
+ if releaseSlow.Load() {
+ answerBinding(request, sender, listener, 40000)
+ }
+ })
+ fast := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
+ answerBinding(request, sender, listener, 40003)
+ })
+ candidates := make(chan *webrtc.ICECandidateInit, 4)
+ gathering := newLocalCandidateGathering(socket, []webrtc.ICEServer{{URLs: []string{
+ "turn:unused.invalid:3478", "stun:" + slow.String(), "stun:" + fast.String(),
+ }}}, nil, func(candidate *webrtc.ICECandidateInit) { candidates <- candidate })
+ t.Cleanup(gathering.close)
+ gathering.start()
+ for _, want := range []struct{ foundation, port string }{{"candidate:ns2", "40003"}, {"candidate:ns1", "40000"}} {
+ select {
+ case candidate := <-candidates:
+ if candidate == nil {
+ t.Fatal("survey ended before both destinations responded")
+ }
+ fields := strings.Fields(candidate.Candidate)
+ if fields[0] != want.foundation || fields[5] != want.port {
+ t.Fatalf("candidate lost its destination: %s; want %+v", candidate.Candidate, want)
+ }
+ releaseSlow.Store(true)
+ case <-time.After(2 * time.Second):
+ t.Fatal("STUN response did not arrive")
+ }
+ }
+}
+
+func TestSurveyPreservesDestinationsWithDuplicateMapping(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ ports [4]int
+ }{{"predictable-survey", [4]int{40000, 40000, 40003, 40006}}, {"one-mapping", [4]int{40000, 40000, 40000, 40000}}} {
+ t.Run(test.name, func(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ socket := testICESocket(t, engine)
+ var releaseSurvey atomic.Bool
+ var urls []string
+ want := make(map[string]string)
+ for index, port := range test.ports {
+ listener := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
+ // ns2 is an extra ordinary destination. Its response arrives
+ // before the survey group ns1/ns3/ns4, sharing ns1's mapping.
+ if index == 1 || releaseSurvey.Load() {
+ answerBinding(request, sender, listener, port)
+ }
+ })
+ urls = append(urls, "stun:"+listener.String())
+ want["candidate:ns"+strconv.Itoa(index+1)] = strconv.Itoa(port)
+ }
+ candidates := make(chan *webrtc.ICECandidateInit, 5)
+ gathering := newLocalCandidateGathering(socket, []webrtc.ICEServer{{URLs: urls}}, nil,
+ func(candidate *webrtc.ICECandidateInit) { candidates <- candidate })
+ t.Cleanup(gathering.close)
+ gathering.start()
+ gathering.addPion(nil)
+ for {
+ select {
+ case candidate := <-candidates:
+ if candidate == nil {
+ if len(want) != 0 {
+ t.Fatalf("mapping deduplication lost destination observations: %v", want)
+ }
+ return
+ }
+ fields := strings.Fields(candidate.Candidate)
+ if want[fields[0]] != fields[5] || (!releaseSurvey.Load() && fields[0] != "candidate:ns2") {
+ t.Fatalf("unexpected destination observation: %s", candidate.Candidate)
+ }
+ delete(want, fields[0])
+ releaseSurvey.Store(true)
+ case <-time.After(2 * time.Second):
+ t.Fatal("survey did not complete")
+ }
+ }
+ })
+ }
+}
+
func TestSurveyReturnsWhenEveryDestinationAlreadyFailed(t *testing.T) {
probe, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback})
if err != nil {
@@ -169,3 +269,42 @@ func TestSurveyReturnsWhenEveryDestinationAlreadyFailed(t *testing.T) {
t.Fatalf("completed failure retained its survey timer: %v", elapsed)
}
}
+
+func TestSurveyHealthySTUNDoesNotWaitForSilentDestination(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ socket := testICESocket(t, engine)
+ silent, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = silent.Close() })
+ servers := []webrtc.ICEServer{{URLs: []string{
+ "stun:" + silent.LocalAddr().String(), localSurveyServer(t),
+ }}}
+ observations := make(chan mappedAddress, 1)
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ socket.surveySTUN(ctx, servers, func(mapped mappedAddress) { observations <- mapped })
+ }()
+ select {
+ case mapped := <-observations:
+ if mapped.port != socket.localPort {
+ t.Fatal("fallback STUN did not observe the media socket")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("healthy fallback waited for the silent STUN destination")
+ }
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("retired survey kept waiting for the silent destination")
+ }
+}
diff --git a/internal/app/mediaedge/edge.go b/internal/app/mediaedge/edge.go
index 53580a4d3..657c452b8 100644
--- a/internal/app/mediaedge/edge.go
+++ b/internal/app/mediaedge/edge.go
@@ -1,13 +1,16 @@
package mediaedge
import (
+ "context"
"errors"
"io"
"log/slog"
"strconv"
"strings"
"sync"
+ "time"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativeaudio"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/pion/ice/v4"
@@ -26,6 +29,7 @@ type EdgeOptions struct {
ICEServers []webrtc.ICEServer
Audio *AudioSource
Local bool
+ Punched bool
Events EdgeEvents
}
@@ -50,6 +54,7 @@ type Edge struct {
qualityMu sync.Mutex
qualityBaseline qualityBaseline
answerMu sync.Mutex
+ localOfferID string
localCandidates *localCandidateGathering
}
@@ -70,7 +75,18 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (_ *Edge, err
return nil, err
}
}
- socket, err := engine.newICESocket(options.ICEServers, !options.Local)
+ var socket *iceSocket
+ if options.Punched && !options.Local {
+ connection := engine.TakePunchedSocket(options.ConnectionID, source)
+ if connection == nil {
+ err = errors.New("prepared connection is unavailable")
+ } else {
+ socket, err = engine.adoptICESocket(connection, options.ICEServers, true)
+ }
+ } else {
+ engine.ClosePunch(options.ConnectionID)
+ socket, err = engine.newICESocket(options.ICEServers, !options.Local)
+ }
if err != nil {
source.releaseReservation(options.Local)
if options.Audio != nil {
@@ -104,7 +120,6 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (_ *Edge, err
}
connection := transport.PC
log := slog.Default().With("connectionId", diagnostics.ID(options.ConnectionID), "rtcPeerId", diagnostics.ID(connection.ID()))
- transport.SetAudioBitrate(options.Audio.configuredBitrate())
edge := &Edge{
connectionID: options.ConnectionID,
engine: engine,
@@ -143,15 +158,14 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (_ *Edge, err
return nil, err
}
}
- edge.localCandidates = newLocalCandidateGathering(
- socket,
- options.ICEServers,
- prepareMapping,
- options.Events.LocalCandidate,
- )
- connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
- edge.localCandidates.addPion(candidate)
- })
+ if !options.Local {
+ edge.localCandidates = newLocalCandidateGathering(
+ socket, options.ICEServers, prepareMapping, options.Events.LocalCandidate,
+ )
+ connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
+ edge.localCandidates.addPion(candidate)
+ })
+ }
connection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Debug("piik-client", "event", "media-ice-state", "direction", "outbound", "local", options.Local, "state", state.String())
})
@@ -226,6 +240,9 @@ func (edge *Edge) SetTargetLayer(layer int) error {
}
func (edge *Edge) CreateOffer() (webrtc.SessionDescription, error) {
+ if edge.local {
+ return webrtc.SessionDescription{}, errors.New("local media requires an identified offer")
+ }
offer, err := edge.connection.CreateOffer(nil)
if err != nil {
return webrtc.SessionDescription{}, err
@@ -237,9 +254,54 @@ func (edge *Edge) CreateOffer() (webrtc.SessionDescription, error) {
return offer, nil
}
-func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error {
+// Local bridges have no STUN or gateway discovery. Complete SDP keeps their
+// candidates and answer in one bounded negotiation, including ICE restarts.
+func (edge *Edge) CreateLocalOffer(ctx context.Context, offerID string, restart bool) (webrtc.SessionDescription, error) {
+ edge.answerMu.Lock()
+ defer edge.answerMu.Unlock()
+ edge.mu.Lock()
+ available := edge.local && !edge.closed && offerID != "" && offerID != edge.localOfferID &&
+ ((!restart && edge.localOfferID == "") || (restart && edge.remoteDescriptionSet))
+ edge.mu.Unlock()
+ if !available {
+ return webrtc.SessionDescription{}, errors.New("local media negotiation is unavailable")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if err := ctx.Err(); err != nil {
+ return webrtc.SessionDescription{}, err
+ }
+ offer, err := edge.connection.CreateOffer(&webrtc.OfferOptions{ICERestart: restart})
+ if err != nil {
+ return webrtc.SessionDescription{}, err
+ }
+ gathered := webrtc.GatheringCompletePromise(edge.connection)
+ if err = edge.connection.SetLocalDescription(offer); err != nil {
+ return webrtc.SessionDescription{}, err
+ }
+ edge.localOfferID = offerID
+ edge.mu.Lock()
+ edge.remoteDescriptionSet = false
+ edge.mu.Unlock()
+ select {
+ case <-gathered:
+ if description := edge.connection.LocalDescription(); description != nil {
+ return *description, nil
+ }
+ return webrtc.SessionDescription{}, io.ErrClosedPipe
+ case <-ctx.Done():
+ return webrtc.SessionDescription{}, ctx.Err()
+ case <-edge.socket.ctx.Done():
+ return webrtc.SessionDescription{}, io.ErrClosedPipe
+ }
+}
+
+func (edge *Edge) SetAnswer(answer webrtc.SessionDescription, offerID string) error {
edge.answerMu.Lock()
defer edge.answerMu.Unlock()
+ if (edge.local && (offerID == "" || offerID != edge.localOfferID)) || (!edge.local && offerID != "") {
+ return errors.New("native media answer does not match its offer")
+ }
if answer.Type != webrtc.SDPTypeAnswer {
return errors.New("native media edge requires an SDP answer")
}
@@ -249,16 +311,23 @@ func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error {
return errors.New("native media edge is closed")
}
if edge.remoteDescriptionSet {
- // The connection identity fences the answer to one edge. A repeated
- // answer is a retransmission; the first applied description remains
- // authoritative.
+ // A repeated answer within the same connection/offer is a
+ // retransmission; its first applied description remains authoritative.
edge.mu.Unlock()
return nil
}
edge.mu.Unlock()
+ if edge.audioSource != nil {
+ if err := forwarding.CheckAudioReceiveLimit(answer.SDP, nativeaudio.MaxBitrate); err != nil {
+ return err
+ }
+ }
if err := edge.connection.SetRemoteDescription(answer); err != nil {
return err
}
+ if err := edge.socket.addPunchedPeer(edge.connection); err != nil {
+ return err
+ }
edge.mu.Lock()
edge.remoteDescriptionSet = true
pending := edge.pendingCandidates
@@ -275,6 +344,9 @@ func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error {
}
func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error {
+ if edge.local {
+ return errors.New("local media candidates belong in the SDP answer")
+ }
if malformedRemoteCandidate(candidate) {
slog.Debug("media-candidate-rejected", "connectionId", diagnostics.ID(edge.connectionID), "stage", "malformed")
return nil
@@ -375,7 +447,7 @@ func (edge *Edge) Close() error {
edge.pendingCandidates = nil
edge.mu.Unlock()
if edge.local && slog.Default().Enabled(edge.engine.ctx, slog.LevelDebug) {
- logLocalMediaCandidateCounts(edge.connection.GetStats())
+ logLocalMediaCandidateCounts(slog.Default(), edge.connection.GetStats())
}
err := edge.transport.Close()
edge.socket.close()
@@ -387,7 +459,7 @@ func (edge *Edge) Close() error {
return err
}
-func logLocalMediaCandidateCounts(report webrtc.StatsReport) {
+func logLocalMediaCandidateCounts(logger *slog.Logger, report webrtc.StatsReport) {
for _, direction := range []webrtc.StatsType{webrtc.StatsTypeLocalCandidate, webrtc.StatsTypeRemoteCandidate} {
for _, kind := range []webrtc.ICECandidateType{webrtc.ICECandidateTypeHost, webrtc.ICECandidateTypeSrflx, webrtc.ICECandidateTypePrflx, webrtc.ICECandidateTypeRelay} {
udp, tcp := 0, 0
@@ -403,7 +475,7 @@ func logLocalMediaCandidateCounts(report webrtc.StatsReport) {
tcp++
}
}
- slog.Debug("piik-client", "event", "media-candidate-counts", "local", true,
+ logger.Debug("piik-client", "event", "media-candidate-counts", "local", true,
"direction", string(direction), "type", kind.String(), "udp", udp, "tcp", tcp)
}
}
diff --git a/internal/app/mediaedge/edge_debug_test.go b/internal/app/mediaedge/edge_debug_test.go
index 419fc5c14..c8930f283 100644
--- a/internal/app/mediaedge/edge_debug_test.go
+++ b/internal/app/mediaedge/edge_debug_test.go
@@ -12,10 +12,8 @@ import (
func TestLocalMediaCandidateCountsExcludePrivateFields(t *testing.T) {
var output bytes.Buffer
- previous := slog.Default()
- slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
- defer slog.SetDefault(previous)
- logLocalMediaCandidateCounts(webrtc.StatsReport{
+ logger := slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ logLocalMediaCandidateCounts(logger, webrtc.StatsReport{
"private-local": webrtc.ICECandidateStats{
ID: "private-local", Type: webrtc.StatsTypeLocalCandidate,
CandidateType: webrtc.ICECandidateTypeHost, Protocol: "udp",
diff --git a/internal/app/mediaedge/edge_test.go b/internal/app/mediaedge/edge_test.go
index d969bfea2..b9ad9494e 100644
--- a/internal/app/mediaedge/edge_test.go
+++ b/internal/app/mediaedge/edge_test.go
@@ -125,12 +125,11 @@ func TestRelayFailureCannotRetireReconfiguredProfile(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
- source, err := engine.NewSource("vp8", 1, 2, nil)
+ source, err := engine.newSource("vp8", 1, 2, &RelayOptions{}, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
- source.relay = &relayDerivation{source: source}
_ = source.SetFormat(1, 640, 360)
profile := nativecapture.VideoProfile{Width: 1280, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"}
if err = source.SetRelayProfile(profile); err != nil {
@@ -182,12 +181,11 @@ func TestRetiredRelayFailureCannotDisableLaterDemand(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
- source, err := engine.NewSource("vp8", 2, 2, nil)
+ source, err := engine.newSource("vp8", 2, 2, &RelayOptions{}, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
- source.relay = &relayDerivation{source: source}
if err = source.SetFormat(1, 640, 360); err != nil {
t.Fatal(err)
}
@@ -467,7 +465,7 @@ func TestRepeatedAnswerIsIdempotent(t *testing.T) {
if answer == nil {
t.Fatal("receiver did not retain its answer")
}
- if err = edge.SetAnswer(*answer); err != nil {
+ if err = edge.SetAnswer(*answer, ""); err != nil {
t.Fatalf("repeated answer was not idempotent: %v", err)
}
}
@@ -485,7 +483,7 @@ func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
- _, receiver, packets := connectedReceiver(t, engine, source, "timed-edge")
+ edge, receiver, packets := connectedReceiver(t, engine, source, "timed-edge")
t.Cleanup(func() { _ = receiver.Close() })
accessUnit := sfu.VP8KeyFrame8x8
start := 10 * time.Second
@@ -494,18 +492,18 @@ func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) {
if err = writeSourceFrame(source, accessUnit, start, frameDuration); err != nil {
t.Fatal(err)
}
- first := waitPacket(t, packets)
+ first := waitPacket(t, packets, edge)
if err = writeSourceFrame(source, accessUnit, start+frameDuration, frameDuration); err != nil {
t.Fatal(err)
}
- second := waitPacket(t, packets)
+ second := waitPacket(t, packets, edge)
if got := second.Timestamp - first.Timestamp; got < 2_999 || got > 3_001 {
t.Fatalf("steady timestamp delta = %d", got)
}
if err = writeSourceFrame(source, accessUnit, start+5*time.Second, frameDuration); err != nil {
t.Fatal(err)
}
- third := waitPacket(t, packets)
+ third := waitPacket(t, packets, edge)
if got := third.Timestamp - second.Timestamp; got < 446_999 || got > 447_001 {
t.Fatalf("sparse timestamp delta = %d", got)
}
@@ -521,19 +519,19 @@ func TestNewCaptureGenerationKeepsTheRTPClockContinuous(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- _, receiver, packets := connectedReceiver(t, engine, source, "generation-edge")
+ edge, receiver, packets := connectedReceiver(t, engine, source, "generation-edge")
t.Cleanup(func() { _ = receiver.Close() })
accessUnit := sfu.VP8KeyFrame8x8
frameDuration := time.Second / 30
if err = writeSourceFrame(source, accessUnit, 50*time.Second, frameDuration); err != nil {
t.Fatal(err)
}
- first := waitPacket(t, packets)
+ first := waitPacket(t, packets, edge)
source.BeginGeneration()
if err = writeSourceFrame(source, accessUnit, 100*time.Millisecond, frameDuration); err != nil {
t.Fatal(err)
}
- second := waitPacket(t, packets)
+ second := waitPacket(t, packets, edge)
if got := second.Timestamp - first.Timestamp; got < 2_999 || got > 3_001 {
t.Fatalf("generation timestamp delta = %d", got)
}
@@ -696,11 +694,11 @@ func TestAudioSourceDeliversOpusOnTheVideoPeerConnection(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
- video, err := engine.NewSource("h264", 1, 1, nil)
+ video, err := engine.NewSource("h264", 2, 1, nil)
if err != nil {
t.Fatal(err)
}
- audio, err := engine.NewAudioSource(1, 128_000)
+ audio, err := engine.NewAudioSource(2, 128_000)
if err != nil {
t.Fatal(err)
}
@@ -722,6 +720,39 @@ func TestAudioSourceDeliversOpusOnTheVideoPeerConnection(t *testing.T) {
}
})
connectEdgeToReceiver(t, edge, receiver)
+ limited, err := engine.NewEdge(video, EdgeOptions{ConnectionID: "limited-audio-edge", Audio: audio})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = limited.Close() })
+ limitedReceiver := newAudioReceiver(t)
+ t.Cleanup(func() { _ = limitedReceiver.Close() })
+ offer, err := limited.CreateOffer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = limitedReceiver.SetRemoteDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ answer, err := (&Receiver{connection: limitedReceiver}).createAnswer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ answer.SDP = strings.ReplaceAll(answer.SDP, "maxaveragebitrate=510000", "maxaveragebitrate=192000")
+ if err = limited.SetAnswer(answer, ""); err == nil {
+ t.Fatal("shared audio accepted an insufficient receiver limit")
+ }
+ if limited.connection.RemoteDescription() != nil {
+ t.Fatal("rejected answer was applied before admission")
+ }
+ if err = limited.Close(); err != nil {
+ t.Fatal(err)
+ }
+ // Rejecting and retiring this edge must leave the shared audio source and
+ // the first receiver usable, without reducing the source's live setting.
+ if audio.configuredBitrate() != 128_000 {
+ t.Fatal("receiver admission changed the shared audio setting")
+ }
if err = audio.WritePCM(make([]byte, nativeaudio.FrameBytes), 20*time.Millisecond); err != nil {
t.Fatal(err)
}
@@ -742,7 +773,17 @@ func connectedReceiver(
connectionID string,
) (*Edge, *webrtc.PeerConnection, <-chan *rtp.Packet) {
t.Helper()
- edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: connectionID})
+ connected := make(chan struct{}, 1)
+ edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: connectionID, Events: EdgeEvents{
+ ConnectionState: func(state webrtc.PeerConnectionState, _ *SelectedPair) {
+ if state == webrtc.PeerConnectionStateConnected {
+ select {
+ case connected <- struct{}{}:
+ default:
+ }
+ }
+ },
+ }})
if err != nil {
t.Fatal(err)
}
@@ -765,6 +806,9 @@ func connectedReceiver(
})
connectEdgeToReceiver(t, edge, receiver)
+ // Pion publishes Connected before our callback activates the media output.
+ // A one-frame test must await that callback, not race it with its only frame.
+ waitSignal(t, connected, "media activation")
return edge, receiver, packets
}
@@ -783,15 +827,13 @@ func connectEdgeToReceiver(
t.Fatal(err)
}
gatherAnswer := webrtc.GatheringCompletePromise(receiver)
- answer, err := receiver.CreateAnswer(nil)
+ // Use the product's receive preferences rather than Pion's generic defaults.
+ _, err := (&Receiver{connection: receiver}).createAnswer()
if err != nil {
t.Fatal(err)
}
- if err = receiver.SetLocalDescription(answer); err != nil {
- t.Fatal(err)
- }
waitSignal(t, gatherAnswer, "answer ICE gathering")
- if err = edge.SetAnswer(*receiver.LocalDescription()); err != nil {
+ if err = edge.SetAnswer(*receiver.LocalDescription(), ""); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
@@ -828,9 +870,6 @@ func newReceiverWithAudio(t *testing.T, includeAudio bool, bindAddress string) *
}
}
registry := &interceptor.Registry{}
- if err := webrtc.ConfigureTWCCSender(mediaEngine, registry); err != nil {
- t.Fatal(err)
- }
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
t.Fatal(err)
}
@@ -876,12 +915,22 @@ func waitSignal(t *testing.T, signal <-chan struct{}, label string) {
}
}
-func waitPacket(t *testing.T, packets <-chan *rtp.Packet) *rtp.Packet {
+func waitPacket(t *testing.T, packets <-chan *rtp.Packet, edges ...*Edge) *rtp.Packet {
t.Helper()
select {
case packet := <-packets:
return packet
case <-time.After(5 * time.Second):
+ for _, edge := range edges {
+ t.Logf("edge=%s output=%+v egress=%+v source=%+v", edge.State(),
+ edge.transport.Output.State(), edge.transport.Egress(), edge.source.media.DebugInfo())
+ t.Logf("forwarder=%+v", edge.transport.Output.DebugInfo())
+ for index, input := range edge.source.media.GetAllBuffers() {
+ if input != nil {
+ t.Logf("input=%d stats=%+v", index, input.GetStats())
+ }
+ }
+ }
t.Fatal("timed out waiting for RTP")
return nil
}
diff --git a/internal/app/mediaedge/engine.go b/internal/app/mediaedge/engine.go
index 3f444b52c..390f3badb 100644
--- a/internal/app/mediaedge/engine.go
+++ b/internal/app/mediaedge/engine.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -18,7 +19,7 @@ import (
// Use the browser-standard spelling of Constrained Baseline in SDP. Pion's
// codec ordering prefers literal profile matches over equivalent constraint bits.
-const H264ProfileLevelID = "42e033"
+const H264ProfileLevelID = "42e034"
var h264Capability = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
@@ -67,6 +68,7 @@ type Engine struct {
publications map[*Publication]struct{}
sockets map[*iceSocket]struct{}
closed bool
+ punch *PunchPreparation
}
func NewEngine(options EngineOptions) (*Engine, error) {
@@ -83,7 +85,18 @@ func NewEngine(options EngineOptions) (*Engine, error) {
mediaEngine := &webrtc.MediaEngine{}
for _, codec := range []string{"h264", "vp8"} {
- if err := mediaEngine.RegisterCodec(videoCodecs[codec], webrtc.RTPCodecTypeVideo); err != nil {
+ primary := videoCodecs[codec]
+ if err := mediaEngine.RegisterCodec(primary, webrtc.RTPCodecTypeVideo); err != nil {
+ return nil, err
+ }
+ // Keep the standard repair stream paired with its media codec. Reusing
+ // old primary sequence numbers cannot repair packets outside SRTP's
+ // replay window after a network outage (RFC 4588).
+ if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
+ RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeRTX,
+ ClockRate: videoClockRate, SDPFmtpLine: "apt=" + strconv.Itoa(int(primary.PayloadType))},
+ PayloadType: primary.PayloadType + 1,
+ }, webrtc.RTPCodecTypeVideo); err != nil {
return nil, err
}
}
@@ -137,6 +150,10 @@ func stunServers(servers []webrtc.ICEServer) []webrtc.ICEServer {
// NewSource coalesces recovery requests by physical output slot; [-1] requests
// all outputs. The callback runs outside the source lock and forwarding path.
func (engine *Engine) NewSource(codec string, capacity, layers int, requestKeyFrame func([]int)) (*Source, error) {
+ return engine.newSource(codec, capacity, layers, nil, requestKeyFrame)
+}
+
+func (engine *Engine) newSource(codec string, capacity, layers int, relay *RelayOptions, requestKeyFrame func([]int)) (*Source, error) {
_, supported := videoCodecs[codec]
if !supported {
return nil, errors.New("native video codec is unsupported")
@@ -157,6 +174,15 @@ func (engine *Engine) NewSource(codec string, capacity, layers int, requestKeyFr
capacity: capacity, requestKeyFrame: requestKeyFrame, edges: make(map[*Edge]bool),
publications: make(map[*Publication]struct{}),
}
+ // Binding forwarding buffers can start asynchronous PLI callbacks. Publish
+ // their immutable inputs before binding, and start our worker only on success.
+ if requestKeyFrame != nil {
+ source.recoveryRequests = make(chan struct{}, 1)
+ source.recoveryStopped = make(chan struct{})
+ }
+ if relay != nil {
+ source.relay = &relayDerivation{source: source, options: *relay}
+ }
media, err := forwarding.NewEncodedSource(forwarding.SourceOptions{
ID: "screen", StreamID: "piik-native", Codec: videoCodecs[codec],
Formats: make([]forwarding.LayerFormat, layers), MaxPackets: encoded.MaxPacketWindow,
@@ -174,8 +200,6 @@ func (engine *Engine) NewSource(codec string, capacity, layers int, requestKeyFr
}
source.media = media
if requestKeyFrame != nil {
- source.recoveryRequests = make(chan struct{}, 1)
- source.recoveryStopped = make(chan struct{})
go source.runRecovery()
}
return source, nil
@@ -218,6 +242,7 @@ func (engine *Engine) Close() error {
sockets = append(sockets, socket)
}
engine.mu.Unlock()
+ engine.ClosePunch("")
closeEdges(edges)
for _, publication := range publications {
_ = publication.Close()
diff --git a/internal/app/mediaedge/ice_socket.go b/internal/app/mediaedge/ice_socket.go
index c3e2bac4a..c117a2051 100644
--- a/internal/app/mediaedge/ice_socket.go
+++ b/internal/app/mediaedge/ice_socket.go
@@ -3,17 +3,18 @@ package mediaedge
import (
"context"
"errors"
+ "fmt"
"log/slog"
"net"
"net/netip"
- "strconv"
"sync"
"time"
"github.com/TNTcraftHIM/Piik/internal/app/portmapping"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/pion/ice/v4"
- "github.com/pion/stun/v3"
+ "github.com/pion/stun/v4"
"github.com/pion/webrtc/v4"
"golang.org/x/sync/singleflight"
)
@@ -32,6 +33,7 @@ type iceSocket struct {
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
+ punchedPeer *net.UDPAddr
}
func (engine *Engine) newICESocket(servers []webrtc.ICEServer, mapping bool) (*iceSocket, error) {
@@ -41,14 +43,23 @@ func (engine *Engine) newICESocket(servers []webrtc.ICEServer, mapping bool) (*i
if err != nil {
return nil, errors.New("native media UDP socket is unavailable")
}
+ return engine.adoptICESocket(connection, servers, mapping)
+}
+
+// Adoption happens before the one PeerConnection is constructed. Discovery and
+// media then keep this exact socket, including any authenticated punched tuple.
+func (engine *Engine) adoptICESocket(connection net.PacketConn, servers []webrtc.ICEServer, mapping bool) (*iceSocket, error) {
ctx, cancel := context.WithCancel(engine.ctx)
socket := &iceSocket{
engine: engine, ctx: ctx, cancel: cancel,
localPort: connection.LocalAddr().(*net.UDPAddr).Port,
- mux: ice.NewUDPMuxDefault(ice.UDPMuxParams{
+ mux: forwarding.NewUDPMux(ice.UDPMuxParams{
Logger: diagnostics.PionLoggerFactory().NewLogger("piik-ice"), UDPConn: connection,
}),
}
+ if prepared, ok := connection.(*PunchedSocket); ok {
+ socket.punchedPeer = prepared.remote
+ }
if engine.portMapping && mapping && len(stunServers(servers)) > 0 {
socket.portMapping = portmapping.Start(socket.localPort)
}
@@ -79,6 +90,16 @@ func (socket *iceSocket) newPeerConnection() (*webrtc.PeerConnection, error) {
).NewPeerConnection(webrtc.Configuration{})
}
+func (socket *iceSocket) addPunchedPeer(connection *webrtc.PeerConnection) error {
+ if socket.punchedPeer == nil {
+ return nil
+ }
+ mid := "0"
+ return connection.AddICECandidate(webrtc.ICECandidateInit{SDPMid: &mid,
+ Candidate: fmt.Sprintf("candidate:sp0 1 udp 1694498815 %s %d typ srflx", socket.punchedPeer.IP, socket.punchedPeer.Port),
+ })
+}
+
func (socket *iceSocket) close() {
if socket == nil {
return
@@ -100,6 +121,8 @@ func (socket *iceSocket) close() {
type mappedAddress struct {
address string
port int
+ // One-based position in the requested STUN list, independent of response order.
+ serverIndex int
}
func (socket *iceSocket) surveySTUN(
@@ -113,8 +136,11 @@ func (socket *iceSocket) surveySTUN(
resolver := net.DefaultResolver
results := make(chan mappedAddress)
var pending sync.WaitGroup
+ index := 0
for _, server := range servers {
for _, rawURL := range server.URLs {
+ index++
+ serverIndex := index
uri, err := stun.ParseURI(rawURL)
if err != nil || uri.Scheme != stun.SchemeTypeSTUN ||
uri.Proto != stun.ProtoTypeUDP || uri.Port < 1 || uri.Port > 65_535 {
@@ -143,7 +169,7 @@ func (socket *iceSocket) surveySTUN(
"localPort", socket.localPort, "mappedAddress", diagnostics.ID(mapped.IP.String()), "mappedPort", mapped.Port,
"durationMs", time.Since(started).Milliseconds())
select {
- case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port}:
+ case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port, serverIndex: serverIndex}:
case <-surveyContext.Done():
}
}()
@@ -153,18 +179,14 @@ func (socket *iceSocket) surveySTUN(
pending.Wait()
close(results)
}()
- seen := map[string]struct{}{}
for {
select {
case value, ok := <-results:
if !ok {
return
}
- key := value.address + ":" + strconv.Itoa(value.port)
- if _, found := seen[key]; found {
- continue
- }
- seen[key] = struct{}{}
+ // The consumer selects survey destinations before deduplicating
+ // mappings. An ordinary response must not hide a survey observation.
emit(value)
case <-surveyContext.Done():
return
diff --git a/internal/app/mediaedge/local_edge_test.go b/internal/app/mediaedge/local_edge_test.go
new file mode 100644
index 000000000..9d3584656
--- /dev/null
+++ b/internal/app/mediaedge/local_edge_test.go
@@ -0,0 +1,94 @@
+package mediaedge
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pion/webrtc/v4"
+)
+
+func TestLocalRestartRetainsMediaAndRejectsStaleAnswers(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ defer cancel()
+ check := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
+ check(err)
+ t.Cleanup(func() { _ = engine.Close() })
+ source, err := engine.NewSource("vp8", 1, 1, nil)
+ check(err)
+ t.Cleanup(func() { _ = source.Close() })
+ edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "local-preview", Local: true})
+ check(err)
+ t.Cleanup(func() { _ = edge.Close() })
+ receiver := newReceiver(t)
+ t.Cleanup(func() { _ = receiver.Close() })
+ connection, transport, sender := edge.connection, edge.transport, edge.sender
+ answer := func(offer webrtc.SessionDescription) webrtc.SessionDescription {
+ t.Helper()
+ check(receiver.SetRemoteDescription(offer))
+ description, err := receiver.CreateAnswer(nil)
+ check(err)
+ gathered := webrtc.GatheringCompletePromise(receiver)
+ check(receiver.SetLocalDescription(description))
+ select {
+ case <-gathered:
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ return *receiver.LocalDescription()
+ }
+ ufrag := func(sdp string) string {
+ for _, line := range strings.Split(sdp, "\r\n") {
+ if strings.HasPrefix(line, "a=ice-ufrag:") {
+ return line
+ }
+ }
+ t.Fatal("missing ICE credentials")
+ return ""
+ }
+ initial, err := edge.CreateLocalOffer(ctx, "local_offer_1", false)
+ check(err)
+ if !strings.Contains(initial.SDP, "a=candidate:") || edge.localCandidates != nil {
+ t.Fatal("local bridge did not use complete SDP")
+ }
+ oldAnswer := answer(initial)
+ check(edge.SetAnswer(oldAnswer, "local_offer_1"))
+ check(edge.SetAnswer(oldAnswer, "local_offer_1")) // Same transaction is idempotent.
+ for _, offerID := range []string{"local_offer_2", "local_offer_3"} {
+ offer, err := edge.CreateLocalOffer(ctx, offerID, true)
+ check(err)
+ if ufrag(initial.SDP) == ufrag(offer.SDP) {
+ t.Fatal("restart reused ICE credentials")
+ }
+ if err = edge.SetAnswer(oldAnswer, "local_offer_1"); err == nil {
+ t.Fatal("stale answer was accepted into a newer negotiation")
+ }
+ if _, err = edge.CreateLocalOffer(ctx, "overlapping_offer", true); err == nil {
+ t.Fatal("an unanswered negotiation was replaced")
+ }
+ check(edge.SetAnswer(answer(offer), offerID))
+ if edge.connection != connection || edge.transport != transport || edge.sender != sender || edge.source != source {
+ t.Fatal("ICE recovery replaced a media owner")
+ }
+ initial = offer
+ }
+ canceled, stop := context.WithCancel(ctx)
+ stop()
+ if _, err = edge.CreateLocalOffer(canceled, "canceled_offer", true); err == nil {
+ t.Fatal("canceled operation changed negotiation")
+ }
+ if edge.localOfferID != "local_offer_3" {
+ t.Fatal("canceled operation replaced offer identity")
+ }
+ check(edge.Close())
+ if _, err = edge.CreateLocalOffer(ctx, "retired_offer", true); err == nil {
+ t.Fatal("restart reopened a retired edge")
+ }
+}
diff --git a/internal/app/mediaedge/publication.go b/internal/app/mediaedge/publication.go
index c04127450..39613630e 100644
--- a/internal/app/mediaedge/publication.go
+++ b/internal/app/mediaedge/publication.go
@@ -82,8 +82,8 @@ func (engine *Engine) NewPublication(source *Source, options EdgeOptions) (*Publ
return nil, err
}
publication.transport = transport
- transport.SetAudioBitrate(options.Audio.configuredBitrate())
publication.signaling.connection = transport.PC
+ publication.signaling.audioSource = options.Audio
publication.signaling.localCandidates = newLocalCandidateGathering(socket, options.ICEServers, nil, options.Events.LocalCandidate)
transport.PC.OnICECandidate(publication.signaling.localCandidates.addPion)
transport.PC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
@@ -117,7 +117,7 @@ func (publication *Publication) CreateOffer() (webrtc.SessionDescription, error)
return publication.signaling.CreateOffer()
}
func (publication *Publication) SetAnswer(answer webrtc.SessionDescription) error {
- return publication.signaling.SetAnswer(answer)
+ return publication.signaling.SetAnswer(answer, "")
}
func (publication *Publication) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error {
return publication.signaling.AddRemoteCandidate(candidate)
@@ -207,7 +207,6 @@ func (publication *Publication) Media() PublicationMedia {
media.Layers[index] = PublicationLayer{RID: forwarding.PublicationRID(index, len(info.Layers)),
Width: layer.Width, Height: layer.Height, Bitrate: layer.Bitrate}
}
- publication.transport.SetAudioBitrate(media.AudioBitrate)
return media
}
diff --git a/internal/app/mediaedge/punch.go b/internal/app/mediaedge/punch.go
new file mode 100644
index 000000000..be3c06458
--- /dev/null
+++ b/internal/app/mediaedge/punch.go
@@ -0,0 +1,358 @@
+package mediaedge
+
+import (
+ "context"
+ "encoding/hex"
+ "errors"
+ "net"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/pion/stun/v4"
+ "github.com/pion/webrtc/v4"
+)
+
+// PunchPrediction is a transient mapping observation, not a NAT
+// classification. Only an authenticated peer in the current route may supply it.
+type PunchPrediction struct {
+ Address string `json:"address"`
+ Port int `json:"port"`
+ Step int `json:"step"`
+}
+
+func (prediction PunchPrediction) Target() (*net.UDPAddr, error) {
+ address, err := netip.ParseAddr(prediction.Address)
+ if err != nil || !address.Is4() || !address.IsGlobalUnicast() || address.IsPrivate() ||
+ address.IsLinkLocalUnicast() || prediction.Step < 0 || prediction.Step > 16 ||
+ prediction.Port < 1024 || prediction.Port > 65535 {
+ return nil, errors.New("unsupported peer mapping observation")
+ }
+ port := prediction.Port + 4*prediction.Step
+ if port > 65535 {
+ return nil, errors.New("unsupported peer mapping observation")
+ }
+ return net.UDPAddrFromAddrPort(netip.AddrPortFrom(address, uint16(port))), nil
+}
+
+// One optional preparation per Engine, within an existing acquisition retry.
+// It owns no media source/copy, persists no address, and expires even if SDP never
+// follows. Cancellation and socket transfer share this owner; there is no retry loop.
+type PunchPreparation struct {
+ engine *Engine
+ connectionID string
+ source *Source
+ servers []webrtc.ICEServer
+ secret []byte
+ ctx context.Context
+ cancel context.CancelFunc
+ mu sync.Mutex
+ workMu sync.Mutex
+ stage string
+ socket *PunchedSocket
+}
+
+// Keep the proved peer tuple with the socket. A later STUN observation on a
+// destination-dependent NAT describes a different mapping, not this path.
+type PunchedSocket struct {
+ net.PacketConn
+ remote *net.UDPAddr
+}
+
+// STUN owns its reader only until Client.Close joins it. Retain the UDP socket
+// across sequential observations, then either close it or use its stable mapping.
+type punchSurveySocket struct {
+ net.PacketConn
+ closed atomic.Bool
+}
+
+func (socket *punchSurveySocket) Close() error {
+ if socket.closed.Swap(true) {
+ return nil
+ }
+ return socket.SetReadDeadline(time.Now())
+}
+
+func (socket *punchSurveySocket) ReadFrom(buffer []byte) (int, net.Addr, error) {
+ if socket.closed.Load() {
+ return 0, nil, net.ErrClosed
+ }
+ return socket.PacketConn.ReadFrom(buffer)
+}
+
+func (socket *punchSurveySocket) WriteTo(buffer []byte, address net.Addr) (int, error) {
+ if socket.closed.Load() {
+ return 0, net.ErrClosed
+ }
+ return socket.PacketConn.WriteTo(buffer, address)
+}
+
+func (engine *Engine) BeginPunch(connectionID string, source *Source, servers []webrtc.ICEServer, secret string) (*PunchPreparation, error) {
+ key, err := hex.DecodeString(secret)
+ if err != nil || len(key) != 32 || connectionID == "" || len(connectionID) > 256 {
+ return nil, errors.New("invalid connection preparation")
+ }
+ if source != nil {
+ source.mu.Lock()
+ defer source.mu.Unlock()
+ if source.engine != engine || source.closed {
+ return nil, errors.New("media source is unavailable")
+ }
+ }
+ engine.mu.Lock()
+ defer engine.mu.Unlock()
+ if engine.closed || engine.punch != nil {
+ return nil, errors.New("connection preparation is unavailable")
+ }
+ ctx, cancel := context.WithTimeout(engine.ctx, 5*time.Second)
+ preparation := &PunchPreparation{
+ engine: engine, connectionID: connectionID, source: source, servers: servers,
+ secret: key, ctx: ctx, cancel: cancel, stage: "new",
+ }
+ engine.punch = preparation
+ context.AfterFunc(ctx, func() {
+ preparation.close()
+ engine.mu.Lock()
+ if engine.punch == preparation {
+ engine.punch = nil
+ }
+ engine.mu.Unlock()
+ })
+ return preparation, nil
+}
+
+// Prepare observes three destinations in order before any peer checks. A stable
+// mapping retains this socket. The ordinary initial ICE attempt never enters here.
+func (preparation *PunchPreparation) Prepare() (*PunchPrediction, error) {
+ if !preparation.workMu.TryLock() {
+ return nil, errors.New("connection preparation is busy")
+ }
+ defer preparation.workMu.Unlock()
+ if !preparation.transition("new", "survey") {
+ return nil, errors.New("connection preparation was retired")
+ }
+ ctx, cancel := context.WithTimeout(preparation.ctx, 1500*time.Millisecond)
+ defer cancel()
+ socket, err := preparation.engine.listenPacket("udp", preparation.engine.bindAddress)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if socket != nil {
+ _ = socket.Close()
+ }
+ }()
+ deadline, _ := ctx.Deadline()
+ if err := socket.SetDeadline(deadline); err != nil {
+ return nil, err
+ }
+ // Pion's initial and retransmission writes are synchronous. Bound both I/O
+ // directions, and join cancellation before this socket can change owners.
+ surveySocket := socket
+ deadlineDone := make(chan struct{})
+ stopDeadline := context.AfterFunc(ctx, func() {
+ defer close(deadlineDone)
+ _ = surveySocket.SetDeadline(time.Now())
+ })
+ stopSurvey := func() {
+ if stopDeadline != nil {
+ if !stopDeadline() {
+ <-deadlineDone
+ }
+ stopDeadline = nil
+ }
+ }
+ defer stopSurvey()
+ var observations []*stun.XORMappedAddress
+ seen := make(map[string]bool)
+ for _, server := range preparation.servers {
+ for _, raw := range server.URLs {
+ uri, parseErr := stun.ParseURI(raw)
+ if parseErr != nil || uri.Scheme != stun.SchemeTypeSTUN || uri.Proto != stun.ProtoTypeUDP {
+ continue
+ }
+ addresses, resolveErr := net.DefaultResolver.LookupNetIP(ctx, "ip4", uri.Host)
+ if resolveErr != nil || len(addresses) == 0 {
+ return nil, errors.New("connection preparation discovery unavailable")
+ }
+ address := net.UDPAddrFromAddrPort(netip.AddrPortFrom(addresses[0], uint16(uri.Port)))
+ if seen[address.String()] {
+ continue
+ }
+ seen[address.String()] = true
+ mapped, mappingErr := querySTUNBinding(ctx, &punchSurveySocket{PacketConn: socket}, address,
+ stun.MustBuild(stun.TransactionID, stun.BindingRequest))
+ if mappingErr != nil {
+ return nil, mappingErr
+ }
+ if err := socket.SetReadDeadline(deadline); err != nil {
+ return nil, err
+ }
+ observations = append(observations, mapped)
+ if len(observations) == 3 {
+ first, second, third := observations[0], observations[1], observations[2]
+ prediction := PunchPrediction{Address: third.IP.String(), Port: third.Port, Step: third.Port - second.Port}
+ _, valid := prediction.Target()
+ if valid != nil || !first.IP.Equal(second.IP) || !second.IP.Equal(third.IP) || second.Port-first.Port != prediction.Step {
+ return nil, errors.New("no usable mapping observation")
+ }
+ stopSurvey()
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if err := socket.SetDeadline(time.Time{}); err != nil {
+ return nil, err
+ }
+ preparation.mu.Lock()
+ defer preparation.mu.Unlock()
+ if preparation.ctx.Err() != nil || preparation.stage != "survey" {
+ return nil, errors.New("connection preparation was retired")
+ }
+ preparation.stage = "ready"
+ if prediction.Step == 0 {
+ // A stable observed endpoint is usable only with this exact socket.
+ preparation.socket = &PunchedSocket{PacketConn: socket}
+ socket = nil
+ }
+ return &prediction, nil
+ }
+ }
+ }
+ return nil, errors.New("three distinct discovery destinations are required")
+}
+
+func (preparation *PunchPreparation) Run(remote PunchPrediction) bool {
+ if !preparation.workMu.TryLock() {
+ return false
+ }
+ defer preparation.workMu.Unlock()
+ target, err := remote.Target()
+ if err != nil || !preparation.transition("ready", "running") {
+ return false
+ }
+ preparation.mu.Lock()
+ var retained net.PacketConn
+ if preparation.socket != nil {
+ retained = preparation.socket.PacketConn
+ preparation.socket = nil
+ }
+ preparation.mu.Unlock()
+ winner, err := punchSocketArray(preparation.ctx, func() (net.PacketConn, error) {
+ return preparation.engine.listenPacket("udp", preparation.engine.bindAddress)
+ }, target, preparation.secret, retained)
+ preparation.mu.Lock()
+ defer preparation.mu.Unlock()
+ if err != nil || preparation.ctx.Err() != nil || preparation.stage != "running" {
+ if winner != nil {
+ _ = winner.Close()
+ }
+ return false
+ }
+ preparation.socket = &PunchedSocket{PacketConn: winner, remote: target}
+ preparation.stage = "complete"
+ return true
+}
+
+func (preparation *PunchPreparation) transition(from, to string) bool {
+ preparation.mu.Lock()
+ defer preparation.mu.Unlock()
+ if preparation.ctx.Err() != nil || preparation.stage != from {
+ return false
+ }
+ preparation.stage = to
+ return true
+}
+
+func (preparation *PunchPreparation) close() {
+ preparation.cancel()
+ preparation.mu.Lock()
+ preparation.stage = "closed"
+ if preparation.socket != nil {
+ _ = preparation.socket.Close()
+ preparation.socket = nil
+ }
+ preparation.mu.Unlock()
+ preparation.workMu.Lock()
+ preparation.workMu.Unlock()
+}
+
+func (engine *Engine) Punch(connectionID string) *PunchPreparation {
+ engine.mu.Lock()
+ defer engine.mu.Unlock()
+ preparation := engine.punch
+ if preparation != nil && preparation.connectionID == connectionID {
+ return preparation
+ }
+ return nil
+}
+
+// TakePunchedSocket transfers ownership once, after both endpoints report a
+// successful preparation. The caller must adopt it or close it on every exit.
+func (engine *Engine) TakePunchedSocket(connectionID string, source *Source) *PunchedSocket {
+ engine.mu.Lock()
+ preparation := engine.punch
+ if preparation == nil || preparation.connectionID != connectionID || preparation.source != source {
+ engine.mu.Unlock()
+ return nil
+ }
+ engine.mu.Unlock()
+ // A premature commit must not free the slot while its workers still own
+ // sockets. Run publishes its result only after its finite work has ended.
+ if !preparation.workMu.TryLock() {
+ return nil
+ }
+ preparation.mu.Lock()
+ if preparation.stage != "complete" || preparation.ctx.Err() != nil {
+ preparation.mu.Unlock()
+ preparation.workMu.Unlock()
+ return nil
+ }
+ connection := preparation.socket
+ preparation.socket = nil
+ preparation.stage = "closed"
+ preparation.mu.Unlock()
+ preparation.workMu.Unlock()
+ preparation.close()
+ engine.mu.Lock()
+ if engine.punch == preparation {
+ engine.punch = nil
+ }
+ engine.mu.Unlock()
+ return connection
+}
+
+// An empty identity is Engine retirement. Source retirement uses the same slot.
+func (engine *Engine) ClosePunch(connectionID string) {
+ engine.mu.Lock()
+ preparation := engine.punch
+ if preparation != nil && connectionID != "" && preparation.connectionID != connectionID {
+ preparation = nil
+ }
+ engine.mu.Unlock()
+ if preparation != nil {
+ preparation.close()
+ engine.mu.Lock()
+ if engine.punch == preparation {
+ engine.punch = nil
+ }
+ engine.mu.Unlock()
+ }
+}
+
+func (engine *Engine) closeSourcePunch(source *Source) {
+ engine.mu.Lock()
+ preparation := engine.punch
+ if preparation != nil && preparation.source != source {
+ preparation = nil
+ }
+ engine.mu.Unlock()
+ if preparation != nil {
+ preparation.close()
+ engine.mu.Lock()
+ if engine.punch == preparation {
+ engine.punch = nil
+ }
+ engine.mu.Unlock()
+ }
+}
diff --git a/internal/app/mediaedge/punch_fixture_test.go b/internal/app/mediaedge/punch_fixture_test.go
new file mode 100644
index 000000000..47f64ee3f
--- /dev/null
+++ b/internal/app/mediaedge/punch_fixture_test.go
@@ -0,0 +1,92 @@
+package mediaedge
+
+import (
+ "bytes"
+ "github.com/pion/logging"
+ "github.com/pion/stun/v4"
+ "github.com/pion/transport/v5/vnet"
+ "net"
+ "testing"
+)
+
+// In-memory TEST-NET addresses only. No public host or OS socket is contacted.
+const punchSTUNIP = "198.51.100.254"
+
+type punchLab struct {
+ wan *vnet.Router
+ nets [2]*vnet.Net
+ private [2]string
+ public [2]string
+}
+
+func newPunchLab(t *testing.T, left, right vnet.NATType) *punchLab {
+ t.Helper()
+ logger := logging.NewDefaultLoggerFactory()
+ logger.DefaultLogLevel = logging.LogLevelError
+ wan, err := vnet.NewRouter(&vnet.RouterConfig{CIDR: "0.0.0.0/0", LoggerFactory: logger})
+ if err != nil {
+ t.Fatal(err)
+ }
+ network := &punchLab{wan: wan, private: [2]string{"10.11.0.2", "10.12.0.2"}, public: [2]string{"198.51.100.1", "198.51.100.2"}}
+ for i, kind := range []vnet.NATType{left, right} {
+ lan, err := vnet.NewRouter(&vnet.RouterConfig{CIDR: network.private[i] + "/24", StaticIPs: []string{network.public[i]}, NATType: &kind, LoggerFactory: logger})
+ if err != nil {
+ t.Fatal(err)
+ }
+ endpoint, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{network.private[i]}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := lan.AddNet(endpoint); err != nil {
+ t.Fatal(err)
+ }
+ if err := wan.AddRouter(lan); err != nil {
+ t.Fatal(err)
+ }
+ network.nets[i] = endpoint
+ }
+ serverNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{punchSTUNIP}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := wan.AddNet(serverNet); err != nil {
+ t.Fatal(err)
+ }
+ if err := wan.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = wan.Stop() })
+ for _, port := range []string{"3478", "3479", "3480"} {
+ startPunchSTUN(t, serverNet, port)
+ }
+ return network
+}
+
+func startPunchSTUN(t *testing.T, serverNet *vnet.Net, port string) {
+ t.Helper()
+ server, err := serverNet.ListenPacket("udp4", punchSTUNIP+":"+port)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stopped := make(chan struct{})
+ t.Cleanup(func() { _ = server.Close(); <-stopped })
+ go func() {
+ defer close(stopped)
+ buffer := make([]byte, 2048)
+ for {
+ n, addr, err := server.ReadFrom(buffer)
+ if err != nil {
+ return
+ }
+ message := &stun.Message{Raw: bytes.Clone(buffer[:n])}
+ if message.Decode() != nil || message.Type != stun.BindingRequest {
+ continue
+ }
+ source := addr.(*net.UDPAddr)
+ reply, err := stun.Build(stun.BindingSuccess, stun.NewTransactionIDSetter(message.TransactionID), &stun.XORMappedAddress{IP: source.IP, Port: source.Port})
+ if err == nil {
+ _, _ = server.WriteTo(reply.Raw, addr)
+ }
+ }
+ }()
+}
diff --git a/internal/app/mediaedge/punch_sockets.go b/internal/app/mediaedge/punch_sockets.go
new file mode 100644
index 000000000..8d04ab5bf
--- /dev/null
+++ b/internal/app/mediaedge/punch_sockets.go
@@ -0,0 +1,137 @@
+package mediaedge
+
+import (
+ "context"
+ "encoding/hex"
+ "errors"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/pion/stun/v4"
+)
+
+// A bounded, authenticated preflight on eight fresh sockets, or the one retained
+// observation socket when its mapping was stable. This function owns retained.
+// All readers stop before the winner transfers to the existing Pion owner.
+func punchSocketArray(ctx context.Context, listen func() (net.PacketConn, error), remote *net.UDPAddr, secret []byte, retained net.PacketConn) (net.PacketConn, error) {
+ var sockets []net.PacketConn
+ count := 8
+ if retained != nil {
+ sockets = append(sockets, retained)
+ count = 1
+ }
+ winner := -1
+ defer func() {
+ for i, connection := range sockets {
+ if i != winner {
+ _ = connection.Close()
+ }
+ }
+ }()
+ if remote == nil || remote.IP.To4() == nil || remote.Port < 1 || remote.Port > 65535 || len(secret) != 32 {
+ return nil, errors.New("invalid peer or generation secret")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 1100*time.Millisecond)
+ defer cancel()
+ for len(sockets) < count {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ connection, err := listen()
+ if err != nil {
+ return nil, err
+ }
+ sockets = append(sockets, connection)
+ }
+ for _, connection := range sockets {
+ deadline, _ := ctx.Deadline()
+ if err := connection.SetDeadline(deadline); err != nil {
+ return nil, err
+ }
+ }
+ integrity := stun.NewShortTermIntegrity(hex.EncodeToString(secret))
+ deadlineDone := make(chan struct{})
+ stopDeadline := context.AfterFunc(ctx, func() {
+ defer close(deadlineDone)
+ for _, connection := range sockets {
+ _ = connection.SetDeadline(time.Now())
+ }
+ })
+ requests := make([]*stun.Message, len(sockets))
+ ready := make(chan int, len(sockets))
+ var readers sync.WaitGroup
+ var responses atomic.Int32
+ for i, connection := range sockets {
+ requests[i] = stun.MustBuild(stun.TransactionID, stun.BindingRequest, integrity, stun.Fingerprint)
+ readers.Add(1)
+ go func() {
+ defer readers.Done()
+ buffer := make([]byte, 1024)
+ for {
+ n, addr, err := connection.ReadFrom(buffer)
+ if err != nil {
+ return
+ }
+ message := &stun.Message{Raw: buffer[:n]}
+ if addr.String() != remote.String() || message.Decode() != nil || integrity.Check(message) != nil {
+ continue
+ }
+ switch message.Type {
+ case stun.BindingRequest:
+ if responses.Add(1) > 64 {
+ continue
+ }
+ reply := stun.MustBuild(stun.BindingSuccess, stun.NewTransactionIDSetter(message.TransactionID),
+ &stun.XORMappedAddress{IP: remote.IP, Port: remote.Port}, integrity, stun.Fingerprint)
+ _, _ = connection.WriteTo(reply.Raw, remote)
+ case stun.BindingSuccess:
+ if message.TransactionID == requests[i].TransactionID {
+ select {
+ case ready <- i:
+ default:
+ }
+ }
+ }
+ }
+ }()
+ }
+ // Keep servicing authenticated peer requests until the finite send window
+ // ends, including after a local response arrives. One peer finishing first
+ // must not strand its partner before socket adoption.
+ for round := 0; round < 8 && ctx.Err() == nil; round++ {
+ for i, connection := range sockets {
+ if ctx.Err() != nil {
+ break
+ }
+ _, _ = connection.WriteTo(requests[i].Raw, remote)
+ }
+ select {
+ case <-ctx.Done():
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ for _, connection := range sockets {
+ _ = connection.SetReadDeadline(time.Now())
+ }
+ readers.Wait()
+ // A cancellation callback must finish before ownership or deadline reset.
+ // Otherwise it can poison the socket after Pion has already adopted it.
+ if !stopDeadline() {
+ <-deadlineDone
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ select {
+ case winner = <-ready:
+ if err := sockets[winner].SetDeadline(time.Time{}); err != nil {
+ winner = -1
+ return nil, err
+ }
+ return sockets[winner], nil
+ default:
+ return nil, errors.New("bounded socket array found no authenticated tuple")
+ }
+}
diff --git a/internal/app/mediaedge/punch_test.go b/internal/app/mediaedge/punch_test.go
new file mode 100644
index 000000000..9810da6a1
--- /dev/null
+++ b/internal/app/mediaedge/punch_test.go
@@ -0,0 +1,473 @@
+package mediaedge
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "math"
+ "net"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/pion/ice/v4"
+ "github.com/pion/stun/v4"
+ "github.com/pion/transport/v5/vnet"
+ "github.com/pion/webrtc/v4"
+)
+
+var sequentialPunchNAT = vnet.NATType{MappingBehavior: vnet.EndpointAddrPortDependent, FilteringBehavior: vnet.EndpointAddrPortDependent}
+
+func punchEngine(t *testing.T, network *punchLab, side int) *Engine {
+ t.Helper()
+ engine, err := NewEngine(EngineOptions{BindAddress: network.private[side] + ":0"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ engine.listenPacket = func(_, address string) (net.PacketConn, error) {
+ return network.nets[side].ListenPacket("udp4", address)
+ }
+ engine.settings.SetNet(network.nets[side])
+ engine.settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ engine.settings.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
+ t.Cleanup(func() { _ = engine.Close() })
+ return engine
+}
+
+func preparePunch(t *testing.T, engine *Engine, source *Source, id, secret string) (*PunchPreparation, *PunchPrediction) {
+ t.Helper()
+ preparation, err := engine.BeginPunch(id, source, []webrtc.ICEServer{{URLs: []string{
+ "stun:" + punchSTUNIP + ":3478", "stun:" + punchSTUNIP + ":3479", "stun:" + punchSTUNIP + ":3480",
+ }}}, secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ prediction, err := preparation.Prepare()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return preparation, prediction
+}
+
+func TestPunchAdoptsProvedTupleIntoMediaAndFeedback(t *testing.T) {
+ stable := vnet.NATType{MappingBehavior: vnet.EndpointIndependent, FilteringBehavior: vnet.EndpointAddrPortDependent}
+ for _, scenario := range []struct {
+ name string
+ left, right vnet.NATType
+ skew time.Duration
+ }{
+ {"sequential", sequentialPunchNAT, sequentialPunchNAT, 0},
+ {"sequential-delayed", sequentialPunchNAT, sequentialPunchNAT, 250 * time.Millisecond},
+ {"stable-sender", stable, sequentialPunchNAT, 250 * time.Millisecond},
+ {"stable-receiver", sequentialPunchNAT, stable, 250 * time.Millisecond},
+ {"both-stable", stable, stable, 0},
+ } {
+ t.Run(scenario.name, func(t *testing.T) {
+ network := newPunchLab(t, scenario.left, scenario.right)
+ engines := [2]*Engine{punchEngine(t, network, 0), punchEngine(t, network, 1)}
+ keyFrames := make(chan struct{}, 8)
+ source, err := engines[0].NewSource("vp8", 1, 1, func([]int) {
+ select {
+ case keyFrames <- struct{}{}:
+ default:
+ }
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = source.Close() })
+ left, leftPrediction := preparePunch(t, engines[0], source, "connection", strings.Repeat("42", 32))
+ right, rightPrediction := preparePunch(t, engines[1], nil, "connection", strings.Repeat("42", 32))
+ results := make(chan bool, 2)
+ go func() { results <- left.Run(*rightPrediction) }()
+ time.Sleep(scenario.skew)
+ go func() { results <- right.Run(*leftPrediction) }()
+ for range 2 {
+ if !<-results {
+ t.Fatal("authenticated preparation failed")
+ }
+ }
+ // No fresh STUN mapping can replace the endpoint that proved this path.
+ socket := engines[1].TakePunchedSocket("connection", nil)
+ if socket == nil {
+ t.Fatal("missing inbound winner")
+ }
+ t.Cleanup(func() { _ = socket.Close() })
+ edge, err := engines[0].NewEdge(source, EdgeOptions{ConnectionID: "connection", Punched: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = edge.Close() })
+ offer, err := edge.CreateOffer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ receiver, answer, err := engines[1].NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1, PunchedSocket: socket})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = receiver.Close() })
+ if err := edge.SetAnswer(answer, ""); err != nil {
+ t.Fatal(err)
+ }
+ waitConnected(t, edge.connection, "adopted Native edge")
+ waitConnected(t, receiver.connection, "adopted Native receiver")
+ deadline := time.Now().Add(3 * time.Second)
+ timestamp := time.Second
+ for {
+ if err := writeSourceFrame(source, sfu.VP8KeyFrame8x8, timestamp, time.Second/30); err != nil {
+ t.Fatal(err)
+ }
+ timestamp += time.Second / 30
+ receiver.mu.Lock()
+ received := receiver.videoSSRC != 0
+ receiver.mu.Unlock()
+ if received {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("adopted socket carried no SRTP video")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ for _, buffer := range source.media.GetAllBuffers() {
+ if buffer != nil {
+ buffer.SetPLIThrottle(0)
+ }
+ }
+ for len(keyFrames) > 0 {
+ <-keyFrames
+ }
+ receiver.RequestKeyFrame()
+ waitSignal(t, keyFrames, "reverse SRTCP feedback")
+ if engines[0].Punch("connection") != nil || engines[1].Punch("connection") != nil {
+ t.Fatal("adoption retained a preparation owner")
+ }
+ })
+ }
+}
+
+func TestPunchRetiresStableObservationSocket(t *testing.T) {
+ stable := vnet.NATType{MappingBehavior: vnet.EndpointIndependent, FilteringBehavior: vnet.EndpointAddrPortDependent}
+ for _, scenario := range []string{"connection", "source", "engine", "cancel-during", "no-response"} {
+ t.Run(scenario, func(t *testing.T) {
+ network := newPunchLab(t, stable, sequentialPunchNAT)
+ engine := punchEngine(t, network, 0)
+ var connections []*countedPunchConn
+ engine.listenPacket = func(_, address string) (net.PacketConn, error) {
+ conn, err := network.nets[0].ListenPacket("udp4", address)
+ if err != nil {
+ return nil, err
+ }
+ counted := &countedPunchConn{PacketConn: conn}
+ connections = append(connections, counted)
+ return counted, nil
+ }
+ source, err := engine.NewSource("vp8", 1, 1, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = source.Close() })
+ preparation, prediction := preparePunch(t, engine, source, "stable", strings.Repeat("42", 32))
+ if prediction.Step != 0 || len(connections) != 1 || connections[0].closed.Load() {
+ t.Fatal("stable observation did not retain its original socket")
+ }
+ if engine.TakePunchedSocket("stable", source) != nil {
+ t.Fatal("observation alone authorized socket adoption")
+ }
+ before := connections[0].writes.Load()
+ switch scenario {
+ case "connection":
+ engine.ClosePunch("stable")
+ case "source":
+ _ = source.Close()
+ case "engine":
+ _ = engine.Close()
+ default:
+ if scenario == "cancel-during" {
+ connections[0].firstWrite = preparation.cancel
+ }
+ if preparation.Run(PunchPrediction{Address: network.public[1], Port: 5000, Step: 1}) {
+ t.Fatal("unreachable peer completed preparation")
+ }
+ engine.ClosePunch("stable")
+ }
+ if len(connections) != 1 || !connections[0].closed.Load() || connections[0].writes.Load()-before > 8 {
+ t.Fatal("stable preparation replaced or leaked its socket, or exceeded its send budget")
+ }
+ })
+ }
+}
+
+func TestPunchRejectsAnotherGenerationOnStableMapping(t *testing.T) {
+ stable := vnet.NATType{MappingBehavior: vnet.EndpointIndependent, FilteringBehavior: vnet.EndpointIndependent}
+ network := newPunchLab(t, stable, stable)
+ leftEngine, rightEngine := punchEngine(t, network, 0), punchEngine(t, network, 1)
+ left, leftPrediction := preparePunch(t, leftEngine, nil, "left", strings.Repeat("42", 32))
+ right, rightPrediction := preparePunch(t, rightEngine, nil, "right", strings.Repeat("24", 32))
+ results := make(chan bool, 2)
+ go func() { results <- left.Run(*rightPrediction) }()
+ go func() { results <- right.Run(*leftPrediction) }()
+ for range 2 {
+ if <-results {
+ t.Fatal("another generation's packets authorized adoption")
+ }
+ }
+ if leftEngine.TakePunchedSocket("left", nil) != nil || rightEngine.TakePunchedSocket("right", nil) != nil {
+ t.Fatal("unauthenticated preparation exposed a socket")
+ }
+}
+
+func TestPunchSurveyViewRetiresWithoutClosingRetainedSocket(t *testing.T) {
+ network := newPunchLab(t, sequentialPunchNAT, sequentialPunchNAT)
+ socket, err := network.nets[0].ListenPacket("udp4", network.private[0]+":0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = socket.Close() })
+ view := &punchSurveySocket{PacketConn: socket}
+ if err := view.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := socket.SetDeadline(time.Now().Add(500 * time.Millisecond)); err != nil {
+ t.Fatal(err)
+ }
+ buffer := make([]byte, 1024)
+ remote := &net.UDPAddr{IP: net.ParseIP(punchSTUNIP), Port: 3478}
+ if _, _, err := view.ReadFrom(buffer); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("closed survey view can read the next owner's socket: %v", err)
+ }
+ if _, err := view.WriteTo(buffer, remote); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("closed survey view can write the next owner's socket: %v", err)
+ }
+ if err := view.Close(); err != nil {
+ t.Fatal(err)
+ }
+ request := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
+ if _, err := socket.WriteTo(request.Raw, remote); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := socket.ReadFrom(buffer); err != nil {
+ t.Fatalf("retiring the view damaged the retained socket: %v", err)
+ }
+}
+
+// A pipe models a backpressured write without timing-sensitive UDP buffers.
+// The first successful send may be dropped to exercise Pion's retransmitter.
+type blockedSurveyConn struct {
+ net.PacketConn
+ writer net.Conn
+ entered chan struct{}
+ blockAt int32
+ writes atomic.Int32
+ closed atomic.Bool
+ once sync.Once
+}
+
+func (c *blockedSurveyConn) WriteTo(p []byte, _ net.Addr) (int, error) {
+ if c.writes.Add(1) < c.blockAt {
+ return len(p), nil
+ }
+ c.once.Do(func() { close(c.entered) })
+ return c.writer.Write(p)
+}
+
+func (c *blockedSurveyConn) SetDeadline(deadline time.Time) error {
+ return errors.Join(c.writer.SetWriteDeadline(deadline), c.PacketConn.SetDeadline(deadline))
+}
+
+func (c *blockedSurveyConn) Close() error {
+ c.closed.Store(true)
+ return errors.Join(c.writer.Close(), c.PacketConn.Close())
+}
+
+func TestPunchSurveyRetiresBlockedWrites(t *testing.T) {
+ for _, phase := range []struct {
+ name string
+ blockAt int32
+ }{{"initial", 1}, {"retransmission", 2}} {
+ for _, retirement := range []string{"cancel", "timeout"} {
+ t.Run(phase.name+"/"+retirement, func(t *testing.T) {
+ network := newPunchLab(t, sequentialPunchNAT, sequentialPunchNAT)
+ engine := punchEngine(t, network, 0)
+ packet, err := network.nets[0].ListenPacket("udp4", network.private[0]+":0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ writer, reader := net.Pipe()
+ socket := &blockedSurveyConn{PacketConn: packet, writer: writer, entered: make(chan struct{}), blockAt: phase.blockAt}
+ t.Cleanup(func() { _ = socket.Close(); _ = reader.Close() })
+ engine.listenPacket = func(_, _ string) (net.PacketConn, error) { return socket, nil }
+ preparation, err := engine.BeginPunch("blocked", nil, []webrtc.ICEServer{{URLs: []string{
+ "stun:" + punchSTUNIP + ":3478", "stun:" + punchSTUNIP + ":3479", "stun:" + punchSTUNIP + ":3480",
+ }}}, strings.Repeat("42", 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { _, err := preparation.Prepare(); done <- err }()
+ waitSignal(t, socket.entered, "blocked STUN write")
+ closed := make(chan struct{})
+ wait := 2 * time.Second
+ if retirement == "cancel" {
+ wait = 500 * time.Millisecond
+ go func() { engine.ClosePunch("blocked"); close(closed) }()
+ }
+ select {
+ case err := <-done:
+ if err == nil || !socket.closed.Load() {
+ t.Fatal("failed survey succeeded or retained its socket")
+ }
+ case <-time.After(wait):
+ t.Fatal("retirement did not release a blocked STUN write")
+ }
+ if retirement == "cancel" {
+ waitSignal(t, closed, "blocked survey owner retirement")
+ } else {
+ engine.ClosePunch("blocked")
+ }
+ if engine.TakePunchedSocket("blocked", nil) != nil {
+ t.Fatal("failed survey exposed a socket")
+ }
+ })
+ }
+ }
+}
+
+func TestPunchRetirementJoinsBeforeSlotReuse(t *testing.T) {
+ for _, retire := range []string{"connection", "source", "engine", "context"} {
+ t.Run(retire, func(t *testing.T) {
+ network := newPunchLab(t, sequentialPunchNAT, sequentialPunchNAT)
+ engine := punchEngine(t, network, 0)
+ source, err := engine.NewSource("vp8", 1, 1, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = source.Close() })
+ preparation, _ := preparePunch(t, engine, source, "current", strings.Repeat("42", 32))
+ // A worker held at an allocation boundary cannot be overtaken by a
+ // premature commit or a replacement connection.
+ entered, release := make(chan struct{}), make(chan struct{})
+ var once sync.Once
+ engine.listenPacket = func(_, _ string) (net.PacketConn, error) {
+ once.Do(func() { close(entered); <-release })
+ return nil, errors.New("retired fixture")
+ }
+ done := make(chan struct{})
+ go func() { preparation.Run(PunchPrediction{Address: network.public[1], Port: 5000, Step: 1}); close(done) }()
+ <-entered
+ if engine.TakePunchedSocket("current", source) != nil || engine.Punch("current") != preparation {
+ t.Fatal("premature commit released the worker's slot")
+ }
+ closed := make(chan struct{})
+ go func() {
+ switch retire {
+ case "connection":
+ engine.ClosePunch("current")
+ case "source":
+ _ = source.Close()
+ case "engine":
+ _ = engine.Close()
+ case "context":
+ preparation.cancel()
+ preparation.close()
+ }
+ close(closed)
+ }()
+ select {
+ case <-closed:
+ t.Fatal("retirement returned before joining its worker")
+ case <-time.After(10 * time.Millisecond):
+ }
+ if _, err := engine.BeginPunch("replacement", nil, nil, strings.Repeat("24", 32)); err == nil {
+ t.Fatal("replacement overtook retiring worker")
+ }
+ close(release)
+ waitSignal(t, done, "preparation worker retirement")
+ waitSignal(t, closed, "preparation owner retirement")
+ })
+ }
+}
+
+type countedPunchConn struct {
+ net.PacketConn
+ closed atomic.Bool
+ writes atomic.Int32
+ firstWrite func()
+}
+
+func (c *countedPunchConn) Close() error { c.closed.Store(true); return c.PacketConn.Close() }
+func (c *countedPunchConn) WriteTo(p []byte, a net.Addr) (int, error) {
+ c.writes.Add(1)
+ if c.firstWrite != nil {
+ c.firstWrite()
+ }
+ return c.PacketConn.WriteTo(p, a)
+}
+
+func TestPunchSocketResourcesAreBoundedOnFailure(t *testing.T) {
+ for _, scenario := range []string{"cancel-before", "cancel-during", "timeout", "allocation-failure", "invalid-secret"} {
+ t.Run(scenario, func(t *testing.T) {
+ network := newPunchLab(t, sequentialPunchNAT, sequentialPunchNAT)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if scenario == "cancel-before" {
+ cancel()
+ }
+ var connections []*countedPunchConn
+ listen := func() (net.PacketConn, error) {
+ if scenario == "allocation-failure" && len(connections) == 3 {
+ return nil, errors.New("fixture allocation failure")
+ }
+ conn, err := network.nets[0].ListenPacket("udp4", network.private[0]+":0")
+ if err != nil {
+ return nil, err
+ }
+ counted := &countedPunchConn{PacketConn: conn}
+ if scenario == "cancel-during" {
+ counted.firstWrite = cancel
+ }
+ connections = append(connections, counted)
+ return counted, nil
+ }
+ secret := bytes.Repeat([]byte{42}, 32)
+ if scenario == "invalid-secret" {
+ secret = nil
+ }
+ winner, err := punchSocketArray(ctx, listen, &net.UDPAddr{IP: net.ParseIP(network.public[1]), Port: 50000}, secret, nil)
+ if err == nil || winner != nil {
+ t.Fatal("failed preparation transferred a socket")
+ }
+ var writes int32
+ for _, conn := range connections {
+ if !conn.closed.Load() {
+ t.Error("preparation leaked a socket")
+ }
+ writes += conn.writes.Load()
+ }
+ if len(connections) > 8 || writes > 64 {
+ t.Fatalf("resource bound exceeded: sockets=%d writes=%d", len(connections), writes)
+ }
+ if (scenario == "invalid-secret" || scenario == "cancel-before") && len(connections) != 0 {
+ t.Fatal("invalid attempt allocated sockets")
+ }
+ })
+ }
+}
+
+func TestPunchPredictionRejectsUnsafeDestinationsAndOverflow(t *testing.T) {
+ for _, prediction := range []PunchPrediction{
+ {Address: "127.0.0.1", Port: 5000, Step: 1}, {Address: "10.0.0.1", Port: 5000, Step: 1},
+ {Address: "224.0.0.1", Port: 5000, Step: 1}, {Address: "::1", Port: 5000, Step: 1},
+ {Address: "198.51.100.1", Port: math.MaxInt, Step: 1}, {Address: "198.51.100.1", Port: 65534, Step: 1},
+ {Address: "198.51.100.1", Port: 1000, Step: 1}, {Address: "198.51.100.1", Port: 5000, Step: 17},
+ {Address: "10.0.0.1", Port: 5000, Step: 0}, {Address: "198.51.100.1", Port: 5000, Step: -1},
+ } {
+ if _, err := prediction.Target(); err == nil {
+ t.Fatalf("accepted unsafe prediction: %+v", prediction)
+ }
+ }
+}
diff --git a/internal/app/mediaedge/quality.go b/internal/app/mediaedge/quality.go
index f164c0573..354c24b74 100644
--- a/internal/app/mediaedge/quality.go
+++ b/internal/app/mediaedge/quality.go
@@ -52,11 +52,9 @@ func (edge *Edge) QualitySample(now time.Time) (QualitySample, bool) {
return QualitySample{}, false
}
frames := framesNow - previous.frames
- bytes := bytesNow - previous.videoBytes + audioBytes - previous.audioBytes
+ videoBytes := bytesNow - previous.videoBytes
+ bytes := videoBytes + audioBytes - previous.audioBytes
seconds := window.Seconds()
- if edge.audioSource != nil && edge.audioSource.encoder == nil {
- edge.transport.SetAudioBitrate(uint32(float64((audioBytes-previous.audioBytes)*8) / seconds))
- }
bitrate := float64(bytes*8) / seconds
sample := QualitySample{
SampleTimestampMs: now.UnixMilli(),
@@ -87,7 +85,9 @@ func (edge *Edge) QualitySample(now time.Time) (QualitySample, bool) {
sample.AvailableOutgoingKbps = float64(target) / 1000
reason := "none"
sample.State = "healthy"
- if edge.transport.Output.IsDeficient() || float64(target) < bitrate {
+ // The estimator tracks video only. Keep total traffic in diagnostics, but
+ // compare its capacity with the corresponding video payload.
+ if edge.transport.Output.IsDeficient() || float64(target) < float64(videoBytes*8)/seconds {
reason = "bandwidth"
sample.State = "degraded"
}
diff --git a/internal/app/mediaedge/quality_test.go b/internal/app/mediaedge/quality_test.go
index 971c8d634..2a64192e7 100644
--- a/internal/app/mediaedge/quality_test.go
+++ b/internal/app/mediaedge/quality_test.go
@@ -7,6 +7,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/pion/rtcp"
)
type qualityAllocationReceiver struct{ sfu.TrackReceiver }
@@ -53,8 +54,11 @@ func TestQualityDimensionsFollowAssignedSource(t *testing.T) {
}
time.Sleep(time.Second / 30)
}
- _, _, format := edge.videoCounters()
- t.Fatalf("quality dimensions still refer to original source: %dx%d", uint32(format>>32), uint32(format))
+ frames, _, format := edge.videoCounters()
+ available, rates := assigned.GetLayeredBitrate()
+ t.Fatalf("assigned quality: %dx%d, frames=%d, source=%t, state=%+v, layers=%v, rates=%v",
+ uint32(format>>32), uint32(format), frames, edge.transport.CurrentSource() == assigned.Source,
+ edge.transport.Output.State(), available, rates)
}
func TestQualitySampleUsesEstimatorCapacityAgainstEncodedPayload(t *testing.T) {
@@ -100,11 +104,14 @@ func TestQualitySampleUsesEstimatorCapacityAgainstEncodedPayload(t *testing.T) {
// Real library allocation remains deficient after selecting a layer that
// fits the link; steady low delivery must not cancel quality convergence.
+ // This fixture supplies observed capacity, beyond first-feedback bootstrap.
+ edge.transport.OnTransportCCFeedback(nil, &rtcp.TransportLayerCC{})
output := edge.transport.Output
output.SetReceiver(qualityAllocationReceiver{TrackReceiver: output.Receiver()})
output.UpTrackMaxPublishedLayerChange(1)
output.UpTrackMaxTemporalLayerSeenChange(0)
output.SetMaxSpatialLayer(1)
+ edge.audioSource = &AudioSource{}
for index, check := range []struct {
budget, sent int
layer int32
@@ -128,8 +135,11 @@ func TestQualitySampleUsesEstimatorCapacityAgainstEncodedPayload(t *testing.T) {
stats.Update(started.Add(time.Duration(number)*time.Second/30).UnixNano(), number,
number*3000, true, 12, size, 0, false)
}
+ // Audio remains in total traffic diagnostics, but cannot turn a healthy
+ // 900 kbps video into an apparent overrun of its 1 Mbps video estimate.
+ edge.audioSource.bytes.Add(80_000) // 320 kbps during this two-second window.
sample, ok = edge.QualitySample(started.Add(time.Duration(index+2) * 2 * time.Second))
- if !ok || sample.State != check.state || sample.BitrateKbps != float64(check.sent)/1000 {
+ if !ok || sample.State != check.state || sample.BitrateKbps != float64(check.sent)/1000+320 {
t.Fatalf("allocation %d quality = %+v, %v", index, sample, ok)
}
}
diff --git a/internal/app/mediaedge/receiver.go b/internal/app/mediaedge/receiver.go
index d9810593d..5ca835811 100644
--- a/internal/app/mediaedge/receiver.go
+++ b/internal/app/mediaedge/receiver.go
@@ -9,7 +9,9 @@ import (
"strings"
"sync"
- "github.com/TNTcraftHIM/Piik/internal/media/encoded"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativeaudio"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
+ "github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/pion/rtcp"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
@@ -27,6 +29,8 @@ type ReceiverOptions struct {
EdgeCapacity int
Events ReceiverEvents
Relay *RelayOptions
+ // A consumed preparation transfers ownership even if offer validation fails.
+ PunchedSocket *PunchedSocket
}
// Receiver owns one inbound WebRTC connection and exposes its encoded media as
@@ -50,6 +54,13 @@ type Receiver struct {
}
func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrtc.SessionDescription, err error) {
+ if options.PunchedSocket != nil {
+ defer func() {
+ if err != nil {
+ _ = options.PunchedSocket.Close()
+ }
+ }()
+ }
if options.Offer.Type != webrtc.SDPTypeOffer || options.Offer.SDP == "" {
return nil, webrtc.SessionDescription{}, errors.New("native media receiver input is invalid")
}
@@ -58,7 +69,12 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrt
return nil, webrtc.SessionDescription{}, err
}
var prepareMapping func() int
- socket, err := engine.newICESocket(options.ICEServers, true)
+ var socket *iceSocket
+ if options.PunchedSocket != nil {
+ socket, err = engine.adoptICESocket(options.PunchedSocket, options.ICEServers, true)
+ } else {
+ socket, err = engine.newICESocket(options.ICEServers, true)
+ }
if err != nil {
return nil, webrtc.SessionDescription{}, err
}
@@ -78,6 +94,10 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrt
_ = connection.Close()
return nil, webrtc.SessionDescription{}, err
}
+ if err = socket.addPunchedPeer(connection); err != nil {
+ _ = connection.Close()
+ return nil, webrtc.SessionDescription{}, err
+ }
videoCodec, err := selectReceiverVideoCodec(connection)
if err != nil {
_ = connection.Close()
@@ -91,10 +111,12 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrt
prepareMapping: prepareMapping,
}
layers := 1
+ var relay *RelayOptions
if _, supported := relayBackend(videoCodec, options.Relay); supported {
layers = 2
+ relay = options.Relay
}
- receiver.source, err = engine.NewSource(videoCodec, options.EdgeCapacity, layers, func([]int) {
+ receiver.source, err = engine.newSource(videoCodec, options.EdgeCapacity, layers, relay, func([]int) {
// Local output slots all derive from this one upstream encoded stream.
receiver.RequestKeyFrame()
})
@@ -102,9 +124,6 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrt
_ = connection.Close()
return nil, webrtc.SessionDescription{}, err
}
- if layers > 1 {
- receiver.source.relay = &relayDerivation{source: receiver.source, options: *options.Relay}
- }
if hasAudio {
receiver.audioSource, err = engine.NewRelayedAudioSource(options.EdgeCapacity)
if err != nil {
@@ -249,7 +268,7 @@ func (receiver *Receiver) createAnswer() (webrtc.SessionDescription, error) {
if !strings.EqualFold(codec.MimeType, webrtc.MimeTypeOpus) {
continue
}
- codec.SDPFmtpLine = opusSDPFmtpLine + ";stereo=1;maxaveragebitrate=192000"
+ codec.SDPFmtpLine = opusSDPFmtpLine + ";stereo=1;maxaveragebitrate=" + strconv.Itoa(nativeaudio.MaxBitrate)
if err := transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec}); err != nil {
return webrtc.SessionDescription{}, err
}
@@ -353,7 +372,7 @@ func (receiver *Receiver) consumeTrack(track *webrtc.TrackRemote, _ *webrtc.RTPR
receiver.videoSSRC = track.SSRC()
receiver.mu.Unlock()
for {
- packet, _, err := track.ReadRTP()
+ packet, _, err := forwarding.ReadRTP(track)
if err != nil {
if !errors.Is(err, io.EOF) {
return
@@ -367,7 +386,7 @@ func (receiver *Receiver) consumeTrack(track *webrtc.TrackRemote, _ *webrtc.RTPR
if track.Kind() == webrtc.RTPCodecTypeAudio && receiver.audioSource != nil &&
strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus) {
for {
- packet, _, err := track.ReadRTP()
+ packet, _, err := forwarding.ReadRTP(track)
if err != nil {
return
}
@@ -436,7 +455,8 @@ func selectReceiverVideoCodec(connection *webrtc.PeerConnection) (string, error)
}
// Pion may fall back to a MIME-only match. Encoded fanout cannot advertise
// Constrained Baseline downstream after accepting a different profile.
- for _, parameters := range transceiver.Receiver().GetParameters().Codecs {
+ negotiated := transceiver.Receiver().GetParameters().Codecs
+ for _, parameters := range negotiated {
name := strings.TrimPrefix(strings.ToLower(parameters.MimeType), "video/")
if _, supported := videoCodecs[name]; !supported {
continue
@@ -444,7 +464,7 @@ func selectReceiverVideoCodec(connection *webrtc.PeerConnection) (string, error)
if name == "h264" && !forwardableH264(parameters.SDPFmtpLine) {
continue
}
- if err := transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{parameters}); err != nil {
+ if err := transceiver.SetCodecPreferences(forwarding.WithRTX(parameters, negotiated)); err != nil {
return "", err
}
return name, nil
@@ -465,15 +485,5 @@ func forwardableH264(fmtp string) bool {
packetization = strings.TrimSpace(value)
}
}
- level, valid := encoded.H264ConstrainedBaselineLevel(profile)
- if !valid || packetization != "1" {
- return false
- }
- // Receiving may include smaller Browser encodes than native capture presets.
- switch level {
- case 10, 11, 12, 13, 20, 21, 22, 30, 31, 32, 40, 41, 42, 50, 51:
- return true
- default:
- return false
- }
+ return packetization == "1" && nativecapture.SupportedH264ProfileLevelID(profile)
}
diff --git a/internal/app/mediaedge/receiver_overlap_test.go b/internal/app/mediaedge/receiver_overlap_test.go
index 7b6b1fef2..082eb7d19 100644
--- a/internal/app/mediaedge/receiver_overlap_test.go
+++ b/internal/app/mediaedge/receiver_overlap_test.go
@@ -51,7 +51,7 @@ func TestNativeReceiverOverlapKeepsCurrentMedia(t *testing.T) {
}
t.Cleanup(func() { _ = receiver.Close() })
waitSignal(t, webrtc.GatheringCompletePromise(receiver.connection), "Native answer")
- if err = edge.SetAnswer(*receiver.connection.LocalDescription()); err != nil {
+ if err = edge.SetAnswer(*receiver.connection.LocalDescription(), ""); err != nil {
t.Fatal(err)
}
waitConnected(t, edge.connection, id)
diff --git a/internal/app/mediaedge/receiver_test.go b/internal/app/mediaedge/receiver_test.go
index 0c6b1b394..3ca8ae47a 100644
--- a/internal/app/mediaedge/receiver_test.go
+++ b/internal/app/mediaedge/receiver_test.go
@@ -3,6 +3,7 @@ package mediaedge
import (
"fmt"
"net"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -12,7 +13,7 @@ import (
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/rtp"
"github.com/pion/sdp/v3"
- "github.com/pion/stun/v3"
+ "github.com/pion/stun/v4"
"github.com/pion/webrtc/v4"
)
@@ -108,7 +109,7 @@ func TestReceiverAdvertisesLocalStereoPreference(t *testing.T) {
continue
}
for _, attribute := range media.Attributes {
- if attribute.Key == "fmtp" && strings.Contains(attribute.Value, "stereo=1") && strings.Contains(attribute.Value, "maxaveragebitrate=192000") {
+ if attribute.Key == "fmtp" && strings.Contains(attribute.Value, "stereo=1") && strings.Contains(attribute.Value, "maxaveragebitrate=510000") {
found = true
}
}
@@ -124,6 +125,114 @@ func TestReceiverAdvertisesLocalStereoPreference(t *testing.T) {
}
}
+func TestReceiverRetainsRepairForOnlyTheSelectedCodec(t *testing.T) {
+ for _, name := range []string{"vp8", "h264"} {
+ t.Run(name, func(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ // An independent stock peer offers its normal primary/RTX pairs.
+ upstream, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = upstream.Close() })
+ transceiver, err := upstream.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
+ webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var preferences []webrtc.RTPCodecParameters
+ for _, codec := range transceiver.Sender().GetParameters().Codecs {
+ if strings.EqualFold(codec.MimeType, "video/"+name) &&
+ (name == "vp8" || strings.Contains(codec.SDPFmtpLine, "profile-level-id=42e01f") &&
+ strings.Contains(codec.SDPFmtpLine, "packetization-mode=1")) || codec.MimeType == webrtc.MimeTypeRTX {
+ preferences = append(preferences, codec)
+ }
+ }
+ if err = transceiver.SetCodecPreferences(preferences); err != nil {
+ t.Fatal(err)
+ }
+ for _, codec := range preferences {
+ if !strings.EqualFold(codec.MimeType, "video/"+name) {
+ continue
+ }
+ track, createErr := webrtc.NewTrackLocalStaticRTP(codec.RTPCodecCapability, "video", "upstream")
+ if createErr != nil {
+ t.Fatal(createErr)
+ }
+ if err = transceiver.Sender().ReplaceTrack(track); err != nil {
+ t.Fatal(err)
+ }
+ break
+ }
+ offer, err := upstream.CreateOffer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = upstream.SetLocalDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ receiver, answer, err := engine.NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = receiver.Close() })
+ for attempt := 0; attempt < 2; attempt++ {
+ var description sdp.SessionDescription
+ if err = description.UnmarshalString(answer.SDP); err != nil {
+ t.Fatal(err)
+ }
+ var codecs []sdp.Codec
+ all := description.GetCodecMap()
+ for _, media := range description.MediaDescriptions {
+ for _, payload := range media.MediaName.Formats {
+ id, _ := strconv.Atoi(payload)
+ codecs = append(codecs, all[uint8(id)])
+ }
+ }
+ if len(codecs) != 2 || receiver.Codec() != name {
+ t.Fatalf("answer must retain one %s primary and its repair codec: %v", name, codecs)
+ }
+ for _, codec := range codecs {
+ if strings.EqualFold(codec.Name, "rtx") {
+ continue
+ }
+ if !strings.EqualFold(codec.Name, receiver.Codec()) {
+ t.Fatalf("unexpected primary codec: %v", codec)
+ }
+ found := false
+ for _, repair := range codecs {
+ found = found || (strings.EqualFold(repair.Name, "rtx") && repair.Fmtp == fmt.Sprintf("apt=%d", codec.PayloadType))
+ }
+ if !found {
+ t.Fatal("answer retained repair for a different primary")
+ }
+ }
+ if err = upstream.SetRemoteDescription(answer); err != nil {
+ t.Fatal(err)
+ }
+ if attempt == 0 {
+ offer, err = upstream.CreateOffer(&webrtc.OfferOptions{ICERestart: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = upstream.SetLocalDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ var reused bool
+ answer, reused, err = receiver.Renegotiate(offer, nil)
+ if err != nil || !reused {
+ t.Fatalf("restart replaced or failed the receiver: %v", err)
+ }
+ }
+ }
+ })
+ }
+}
+
func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) {
for _, preferVP8 := range []bool{false, true} {
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
@@ -180,8 +289,9 @@ func TestReceiverNegotiatesForwardableH264(t *testing.T) {
{"missing-profile", []string{""}, ""},
{"equivalent-baseline", []string{"42c01f"}, "42c01f"},
{"small-browser-output", []string{"42e00b"}, "42e00b"},
- {"level-ceiling", []string{"42e033"}, "42e033"},
- {"over-ceiling", []string{"42e034"}, ""},
+ {"level-5.1", []string{"42e033"}, "42e033"},
+ {"level-ceiling", []string{"42e034"}, "42e034"},
+ {"over-ceiling", []string{"42e03c"}, ""},
{"invalid-level", []string{"42e035"}, ""},
{"invalid-constraints", []string{"42e11f"}, ""},
{"mode-zero", []string{"42e01f;packetization-mode=0"}, ""},
diff --git a/internal/app/mediaedge/relay_chain_fixture_test.go b/internal/app/mediaedge/relay_chain_fixture_test.go
index 7ad132931..b15913eca 100644
--- a/internal/app/mediaedge/relay_chain_fixture_test.go
+++ b/internal/app/mediaedge/relay_chain_fixture_test.go
@@ -44,10 +44,11 @@ func TestRelayChainEncodedFixture(t *testing.T) {
t.Fatal("encoded chain fixture exceeds 64 MiB")
}
type fixtureFrame struct {
- Index, Width, Height int
- Recovery bool
- DataHex string
- Data []byte `json:"-"`
+ Index int
+ Width, Height uint32
+ Recovery bool
+ DataHex string
+ Data []byte `json:"-"`
}
var frames []fixtureFrame
identities := make(map[[sha256.Size]byte][]int)
@@ -59,7 +60,7 @@ func TestRelayChainEncodedFixture(t *testing.T) {
}
check(err)
if len(frames) >= 400 || frame.Index != len(frames) || frame.Width <= 0 || frame.Height <= 0 ||
- frame.Width > 2560 || frame.Height > 1440 || len(frame.DataHex) == 0 || len(frame.DataHex) > 8<<20 ||
+ !nativecapture.ValidVideoSize(frame.Width, frame.Height) || len(frame.DataHex) == 0 || len(frame.DataHex) > 8<<20 ||
len(frame.DataHex)%2 != 0 {
t.Fatal("encoded chain fixture has invalid or excessive frames")
}
@@ -137,7 +138,7 @@ func TestRelayChainEncodedFixture(t *testing.T) {
check(createErr)
relays = append(relays, receiver)
waitSignal(t, webrtc.GatheringCompletePromise(receiver.connection), "chain answer gathering")
- check(edge.SetAnswer(*receiver.connection.LocalDescription()))
+ check(edge.SetAnswer(*receiver.connection.LocalDescription(), ""))
waitConnected(t, edge.connection, "chain upstream")
waitConnected(t, receiver.connection, "chain native receiver")
check(receiver.Source().SetRelayProfile(nativecapture.VideoProfile{
diff --git a/internal/app/mediaedge/relay_derivation.go b/internal/app/mediaedge/relay_derivation.go
index 821b68dcc..1969caf65 100644
--- a/internal/app/mediaedge/relay_derivation.go
+++ b/internal/app/mediaedge/relay_derivation.go
@@ -132,7 +132,7 @@ func (source *Source) relayPlan() relayPlan {
plan.output = nativecapture.ScreenShareOutputs(*plan.profile)[0]
plan.output.Width = min(width/4*2, plan.output.Width)
plan.output.Height = min(height/4*2, plan.output.Height)
- if !plan.output.Valid() || width > 2560 || height > 1440 {
+ if !plan.output.Valid() || !nativecapture.ValidVideoSize(width, height) {
return plan
}
demands := source.collectDemands(edges, nil)
diff --git a/internal/app/mediaedge/relay_fixture_test.go b/internal/app/mediaedge/relay_fixture_test.go
index 79f050b43..3add2670d 100644
--- a/internal/app/mediaedge/relay_fixture_test.go
+++ b/internal/app/mediaedge/relay_fixture_test.go
@@ -51,15 +51,14 @@ func TestRelayDerivationFixture(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
- source, err := engine.NewSource("vp8", 2, 2, nil)
+ source, err := engine.newSource("vp8", 2, 2, &RelayOptions{
+ CaptureProcess: executable,
+ Capabilities: nativecapture.Capabilities{SoftwareVP8: true, Adapters: []nativecapture.Adapter{{Index: 0}}},
+ }, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
- source.relay = &relayDerivation{source: source, options: RelayOptions{
- CaptureProcess: executable,
- Capabilities: nativecapture.Capabilities{SoftwareVP8: true, Adapters: []nativecapture.Adapter{{Index: 0}}},
- }}
profile := nativecapture.VideoProfile{Width: 1280, Height: 720, Framerate: 30, Bitrate: 2_000_000, Preference: "balanced"}
if err = source.SetRelayProfile(profile); err != nil {
t.Fatal(err)
@@ -330,13 +329,12 @@ func TestRelayIndependentSpatialFixture(t *testing.T) {
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
check(err)
defer engine.Close()
- source, err := engine.NewSource("vp8", 2, 2, nil)
- check(err)
- defer source.Close()
- source.relay = &relayDerivation{source: source, options: RelayOptions{
+ source, err := engine.newSource("vp8", 2, 2, &RelayOptions{
CaptureProcess: executable,
Capabilities: nativecapture.Capabilities{SoftwareVP8: true, Adapters: []nativecapture.Adapter{{Index: 0}}},
- }}
+ }, nil)
+ check(err)
+ defer source.Close()
check(source.SetRelayProfile(nativecapture.VideoProfile{
Width: 1920, Height: 1080, Framerate: 30, Bitrate: 5_000_000, Preference: "maintain-framerate",
}))
diff --git a/internal/app/mediaedge/relay_input.go b/internal/app/mediaedge/relay_input.go
index c0e78b4fe..b9959c723 100644
--- a/internal/app/mediaedge/relay_input.go
+++ b/internal/app/mediaedge/relay_input.go
@@ -151,10 +151,19 @@ func relayH264Size(nal []byte) (uint32, uint32, error) {
}
// Constrained Baseline uses 8-bit 4:2:0. Bound uncropped storage before
// allowing padding to be removed from the visible picture.
- if sps.ChromaFormatIdc != 1 || sps.MaxNumRefFrames > 16 ||
- width > nativecapture.MaxVideoWidth || height > nativecapture.MaxVideoHeight {
+ if sps.ChromaFormatIdc != 1 || !nativecapture.ValidCodedVideoSize(width, height) {
return 0, 0, errors.New("native H264 input storage exceeds its bound")
}
+ bufferedFrames := max(1, sps.MaxNumRefFrames)
+ if sps.VUI != nil && sps.VUI.BitstreamRestriction != nil {
+ bufferedFrames = max(bufferedFrames, sps.VUI.BitstreamRestriction.MaxDecFrameBuffering,
+ sps.VUI.BitstreamRestriction.MaxNumReorderFrames)
+ }
+ // Level 5.2 also bounds the decoded-picture buffer. A per-frame size check
+ // alone would let a larger picture multiply the old 16-reference allocation.
+ if bufferedFrames > 16 || width*height/256*uint64(bufferedFrames) > 184_320 {
+ return 0, 0, errors.New("native H264 input reference storage exceeds its bound")
+ }
if crop := sps.FrameCropping; crop != nil {
x := (uint64(crop.LeftOffset) + uint64(crop.RightOffset)) * 2
y := (uint64(crop.TopOffset) + uint64(crop.BottomOffset)) * cropUnitY
diff --git a/internal/app/mediaedge/relay_input_test.go b/internal/app/mediaedge/relay_input_test.go
index 3ba2e0dea..f69602924 100644
--- a/internal/app/mediaedge/relay_input_test.go
+++ b/internal/app/mediaedge/relay_input_test.go
@@ -10,8 +10,12 @@ import (
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
"github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/pion/ice/v4"
+ "github.com/pion/interceptor"
+ "github.com/pion/logging"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
+ "github.com/pion/transport/v5/vnet"
"github.com/pion/webrtc/v4"
)
@@ -56,9 +60,9 @@ func TestRelayInputKeepsRecoveryAndEmitsACompleteQuietFrame(t *testing.T) {
}
func TestRelayInputRejectsUnsafeH264Configuration(t *testing.T) {
- // Locally generated Constrained Baseline SPS: ID 0 is 4096x2160; ID 1 is
+ // Generated Constrained Baseline SPS: ID 0 is 5120x2880; ID 1 is
// 160x90. A decoder may use either, regardless of which arrived last.
- const oversized = "6742c033dc0100010fb016a02020280000030008000003001478c19c"
+ const oversized = "6742c034da0050005a64"
const small = "6742c015570a37e4c05a8303035280000003008000000301478b17c0"
for _, test := range []struct {
name string
@@ -106,9 +110,18 @@ func TestRelayH264AllocationBounds(t *testing.T) {
{"1440p", "6742c033da00a002d640", 2560, 1440},
{"padded-1080p", "6742c033da01e0089f95", 1920, 1080},
{"portrait-within-bound", "6742c033da01100b5e5d", 1080, 1440},
- {"4k", "6742c033da00f0010f90", 0, 0},
- {"portrait-overflow", "6742c033da008701e190", 0, 0},
- {"padded-4k", "6742c033da00f00111f894", 0, 0},
+ {"4k", "6742c033da00f0010f90", 3840, 2160},
+ {"portrait-4k", "6742c033da008701e190", 2160, 3840},
+ {"level-5.2-4k", "6742c034da00f0010f90", 3840, 2160},
+ {"level-5.2-portrait-4k", "6742c034da008701e190", 2160, 3840},
+ {"level-5.2-dci", "6742c034da0040004864", 4096, 2304},
+ {"level-5.2-ultrawide", "6742c034da005000b590", 5120, 1440},
+ {"level-5.2-portrait-ultrawide", "6742c034da0168028190", 1440, 5120},
+ {"padded-4k", "6742c033da00f00111f894", 3840, 2160},
+ {"4k-reference-buffer-limit", "6742c033f300780087c8", 3840, 2160},
+ {"4k-reference-buffer-overflow", "6742c033f380780087c8", 0, 0},
+ {"level-5.2-reference-buffer-overflow", "6742c034f380780087c8", 0, 0},
+ {"level-5.2-cropped-storage-overflow", "6742c034da00f001e1f8034940", 0, 0},
{"large-cropped-storage", "6742c033da00f001e1f8034940", 0, 0},
{"crop-overflow", "6742c033da0a37e000000300100000030004", 0, 0},
{"coded-size-overflow", "6742c033da000003000040000003000d90", 0, 0},
@@ -172,9 +185,27 @@ func TestLargeRecoveryUsesTheDefaultPacketWindow(t *testing.T) {
if len(packets) <= 500 || len(packets) > encoded.MaxPacketWindow {
t.Fatal("fixture does not exercise the packet window boundary")
}
+ // Isolate packet-window capacity from host UDP receive-buffer overflow.
+ // The production sender, SRTP transport and frame assembler still run here.
+ router, err := vnet.NewRouter(&vnet.RouterConfig{CIDR: "10.0.0.0/24",
+ QueueSize: encoded.MaxPacketWindow, LoggerFactory: logging.NewDefaultLoggerFactory()})
+ check(err)
+ sendNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"10.0.0.1"}})
+ check(err)
+ receiveNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"10.0.0.2"}})
+ check(err)
+ check(router.AddNet(sendNet))
+ check(router.AddNet(receiveNet))
+ check(router.Start())
+ defer router.Stop()
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true, InitialBitrate: 12_000_000})
check(err)
defer engine.Close()
+ engine.listenPacket = sendNet.ListenPacket
+ engine.bindAddress = "10.0.0.1:0"
+ engine.settings.SetNet(sendNet)
+ engine.settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ engine.settings.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
source, err := engine.NewSource("h264", 1, 1, nil)
check(err)
defer source.Close()
@@ -183,7 +214,17 @@ func TestLargeRecoveryUsesTheDefaultPacketWindow(t *testing.T) {
edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "large-recovery"})
check(err)
defer edge.Close()
- receiver := newReceiver(t)
+ media := &webrtc.MediaEngine{}
+ check(media.RegisterDefaultCodecs())
+ registry := &interceptor.Registry{}
+ check(webrtc.RegisterDefaultInterceptors(media, registry))
+ settings := webrtc.SettingEngine{}
+ settings.SetNet(receiveNet)
+ settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ settings.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
+ receiver, err := webrtc.NewAPI(webrtc.WithMediaEngine(media), webrtc.WithInterceptorRegistry(registry),
+ webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
+ check(err)
defer receiver.Close()
var complete atomic.Bool
receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
diff --git a/internal/app/mediaedge/relay_network_fixture_test.go b/internal/app/mediaedge/relay_network_fixture_test.go
index a0e82c72f..9d80ef25e 100644
--- a/internal/app/mediaedge/relay_network_fixture_test.go
+++ b/internal/app/mediaedge/relay_network_fixture_test.go
@@ -19,7 +19,7 @@ import (
"github.com/pion/rtcp"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
- "github.com/pion/transport/v4/vnet"
+ "github.com/pion/transport/v5/vnet"
"github.com/pion/webrtc/v4"
)
@@ -66,13 +66,12 @@ func TestRelayNetworkVP8Fixture(t *testing.T) {
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", InitialBitrate: 1_200_000})
check(err)
defer engine.Close()
- source, err := engine.NewSource("vp8", 2, 2, nil)
- check(err)
- defer source.Close()
- source.relay = &relayDerivation{source: source, options: RelayOptions{
+ source, err := engine.newSource("vp8", 2, 2, &RelayOptions{
CaptureProcess: os.Getenv("PIIK_NATIVE_CAPTURE"),
Capabilities: nativecapture.Capabilities{SoftwareVP8: true, Adapters: []nativecapture.Adapter{{Index: 0}}},
- }}
+ }, nil)
+ check(err)
+ defer source.Close()
check(source.SetRelayProfile(nativecapture.VideoProfile{Width: 1280, Height: 720,
Framerate: 30, Bitrate: 2_000_000, Preference: "balanced"}))
router, err := vnet.NewRouter(&vnet.RouterConfig{CIDR: "10.0.0.0/24", MinDelay: 20 * time.Millisecond,
@@ -124,7 +123,6 @@ func TestRelayNetworkVP8Fixture(t *testing.T) {
check(media.RegisterDefaultCodecs())
registry := &interceptor.Registry{}
check(webrtc.RegisterDefaultInterceptors(media, registry))
- check(webrtc.ConfigureTWCCSender(media, registry))
receiver, createErr := webrtc.NewAPI(webrtc.WithMediaEngine(media), webrtc.WithInterceptorRegistry(registry),
webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
check(createErr)
diff --git a/internal/app/mediaedge/source.go b/internal/app/mediaedge/source.go
index 77a781fae..e23ebdbcd 100644
--- a/internal/app/mediaedge/source.go
+++ b/internal/app/mediaedge/source.go
@@ -695,6 +695,7 @@ func (source *Source) Close() error {
}
source.mu.Unlock()
// Close transports before waiting on their possible writes into this group.
+ source.engine.closeSourcePunch(source)
closeEdges(edges)
for _, publication := range publications {
_ = publication.Close()
diff --git a/internal/app/mediaedge/source_test.go b/internal/app/mediaedge/source_test.go
index 7d70a28d4..c9687a788 100644
--- a/internal/app/mediaedge/source_test.go
+++ b/internal/app/mediaedge/source_test.go
@@ -56,6 +56,37 @@ func TestSourceRecoveryCoalescesTargetsWithoutWakingUnrelatedOutputs(t *testing.
}
}
+func TestSourceStartupFeedbackRequestsRecovery(t *testing.T) {
+ for _, relay := range []*RelayOptions{nil, {}} {
+ t.Run(fmt.Sprintf("relay-%t", relay != nil), func(t *testing.T) {
+ requested := make(chan []int, 4)
+ source, err := (&Engine{}).newSource("vp8", 2, 2, relay, func(layers []int) {
+ select {
+ case requested <- layers:
+ default:
+ }
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer source.Close()
+ // LiveKit can request startup keyframes as soon as its buffers are bound.
+ // Wait for that real asynchronous callback, without manually sending PLI.
+ select {
+ case layers := <-requested:
+ if len(layers) == 0 {
+ t.Fatal("startup recovery has no output identity")
+ }
+ if (source.groupRecovery.Load() != 0) != (relay != nil) {
+ t.Fatal("startup recovery used the wrong capture/relay owner")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("startup feedback lost its recovery notification")
+ }
+ })
+ }
+}
+
func TestSourceRecoveryRetainsLocalPlanWithoutDirectCaptureCallback(t *testing.T) {
for _, relay := range []bool{false, true} {
t.Run(fmt.Sprint(relay), func(t *testing.T) {
diff --git a/internal/app/mediaedge/stun.go b/internal/app/mediaedge/stun.go
index c6e3b4e22..f91c4eb3e 100644
--- a/internal/app/mediaedge/stun.go
+++ b/internal/app/mediaedge/stun.go
@@ -6,7 +6,7 @@ import (
"net"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
- "github.com/pion/stun/v3"
+ "github.com/pion/stun/v4"
)
// Only in-flight discovery is shared. Completed observations must not survive
@@ -46,6 +46,10 @@ func (socket *iceSocket) querySTUN(ctx context.Context, server *net.UDPAddr) (*s
}
defer socket.mux.RemoveConnByUfrag(key)
defer connection.Close()
+ return querySTUNBinding(ctx, connection, server, request)
+}
+
+func querySTUNBinding(ctx context.Context, connection net.PacketConn, server *net.UDPAddr, request *stun.Message) (*stun.XORMappedAddress, error) {
client, err := stun.NewClient(&stunConnection{PacketConn: connection, server: server},
stun.WithLoggerFactory(diagnostics.PionLoggerFactory()))
if err != nil {
@@ -82,7 +86,7 @@ func (socket *iceSocket) querySTUN(ctx context.Context, server *net.UDPAddr) (*s
}
// Pion's STUN client owns transaction matching and retransmission. This view
-// only addresses its packets on the existing media mux.
+// only addresses its packets to the current discovery destination.
type stunConnection struct {
net.PacketConn
server *net.UDPAddr
diff --git a/internal/app/mediaedge/stun_test.go b/internal/app/mediaedge/stun_test.go
index d41f278ff..f4beaa41a 100644
--- a/internal/app/mediaedge/stun_test.go
+++ b/internal/app/mediaedge/stun_test.go
@@ -11,7 +11,7 @@ import (
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/rtp"
- "github.com/pion/stun/v3"
+ "github.com/pion/stun/v4"
"github.com/pion/webrtc/v4"
)
diff --git a/internal/app/nativeaudio/encoder.go b/internal/app/nativeaudio/encoder.go
index d037d088c..0f17e4dde 100644
--- a/internal/app/nativeaudio/encoder.go
+++ b/internal/app/nativeaudio/encoder.go
@@ -8,12 +8,18 @@ import (
)
const (
- SampleRate = 48_000
- Channels = 2
- FrameSamples = 960
- FrameBytes = FrameSamples * Channels * 2
- MaxPacketBytes = 4_000
- DefaultBitrate = 128_000
+ SampleRate = 48_000
+ Channels = 2
+ FrameSamples = 960
+ FrameBytes = FrameSamples * Channels * 2
+ // Opus RTP keeps each frame intact. Bound VBR peaks at the encoder, reserving
+ // 48 bytes for RTP, header extensions and SRTP within a 1200-byte datagram.
+ MaxPacketBytes = 1_152
+ DefaultBitrate = 128_000
+ MinBitrate = 6_000
+ MaxBitrate = 510_000
+ // Keep the 20 ms target below the per-packet ceiling, including VBR headroom.
+ MaxSendBitrate = 320_000
defaultComplexity = 5
)
@@ -29,14 +35,14 @@ type Encoder struct {
func (encoder *Encoder) Bitrate() int { return encoder.codec.Bitrate() }
func (encoder *Encoder) SetBitrate(bitrate int) error {
- if encoder == nil || encoder.codec == nil || bitrate < 6_000 || bitrate > 510_000 {
+ if encoder == nil || encoder.codec == nil || bitrate < MinBitrate || bitrate > MaxBitrate {
return errors.New("native audio bitrate is outside the Opus range")
}
return encoder.codec.SetBitrate(bitrate)
}
func NewEncoder(bitrate int) (*Encoder, error) {
- if bitrate < 6_000 || bitrate > 510_000 {
+ if bitrate < MinBitrate || bitrate > MaxBitrate {
return nil, errors.New("native audio bitrate is outside the Opus range")
}
codec, err := gopus.NewEncoder(gopus.EncoderConfig{
diff --git a/internal/app/nativeaudio/encoder_test.go b/internal/app/nativeaudio/encoder_test.go
index 074405420..091d75aaf 100644
--- a/internal/app/nativeaudio/encoder_test.go
+++ b/internal/app/nativeaudio/encoder_test.go
@@ -3,6 +3,7 @@ package nativeaudio
import (
"encoding/binary"
"math"
+ "math/rand/v2"
"testing"
gopus "github.com/thesyncim/gopus"
@@ -86,7 +87,7 @@ func TestEncodePreservesAudiblePCM(t *testing.T) {
}
func TestEncodePreservesStereoSeparation(t *testing.T) {
- for _, bitrate := range []int{64_000, 128_000, 192_000} {
+ for _, bitrate := range []int{64_000, 128_000, 192_000, 256_000, 320_000} {
encoder, err := NewEncoder(bitrate)
if err != nil {
t.Fatal(err)
@@ -150,13 +151,35 @@ func TestEncoderUpdatesBitrateWithinTheSameGeneration(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- for _, bitrate := range []int{64_000, 192_000, DefaultBitrate} {
+ decoder, err := gopus.NewDecoder(gopus.DefaultDecoderConfig(SampleRate, Channels))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pcm := make([]byte, FrameBytes)
+ decoded := make([]int16, FrameSamples*Channels)
+ for _, bitrate := range []int{64_000, 192_000, 256_000, 320_000, 96_000, DefaultBitrate} {
if err = encoder.SetBitrate(bitrate); err != nil {
t.Fatalf("SetBitrate(%d) = %v", bitrate, err)
}
+ if encoder.Bitrate() != bitrate {
+ t.Fatalf("SetBitrate(%d) readback = %d", bitrate, encoder.Bitrate())
+ }
+ packet, err := encoder.Encode(pcm)
+ if err != nil {
+ t.Fatal(err)
+ }
+ count, err := decoder.DecodeInt16(packet, decoded)
+ if err != nil || count != FrameSamples {
+ t.Fatalf("decode after bitrate %d: %d samples, %v", bitrate, count, err)
+ }
}
- if err = encoder.SetBitrate(0); err == nil {
- t.Fatal("invalid live bitrate was accepted")
+ for _, bitrate := range []int{0, 5_999, 510_001} {
+ if err = encoder.SetBitrate(bitrate); err == nil {
+ t.Fatalf("invalid live bitrate %d was accepted", bitrate)
+ }
+ if encoder.Bitrate() != DefaultBitrate {
+ t.Fatalf("rejected bitrate %d replaced the active setting", bitrate)
+ }
}
}
@@ -178,3 +201,54 @@ func TestEncodeSteadyStateAllocationIsBounded(t *testing.T) {
t.Fatalf("steady-state Opus encoding allocated %.1f objects", allocations)
}
}
+
+func TestEncodeBoundsTransientPacketsAcrossBitrateChanges(t *testing.T) {
+ encoder, err := NewEncoder(DefaultBitrate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoder, err := gopus.NewDecoder(gopus.DefaultDecoderConfig(SampleRate, Channels))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pcm := make([]byte, FrameBytes)
+ decoded := make([]int16, FrameSamples*Channels)
+ random := rand.New(rand.NewPCG(472, 20261014))
+ for _, bitrate := range []int{DefaultBitrate, MaxSendBitrate, MinBitrate, 12_345, MaxSendBitrate} {
+ if err := encoder.SetBitrate(bitrate); err != nil {
+ t.Fatal(err)
+ }
+ for block := range 100 {
+ for frame := range FrameSamples {
+ for channel := range Channels {
+ sample := 0.0
+ switch (block / 10) % 5 {
+ case 1:
+ sample = 26_000 * math.Sin(2*math.Pi*float64(500+channel*1000)*float64(block*FrameSamples+frame)/SampleRate)
+ case 2:
+ sample = 32_767 * (random.Float64()*2 - 1)
+ case 3:
+ if frame < 10 {
+ sample = 32_767
+ }
+ case 4:
+ sample = float64(32_767 - 65_535*(frame%2))
+ }
+ binary.LittleEndian.PutUint16(pcm[(frame*Channels+channel)*2:], uint16(int16(sample)))
+ }
+ }
+ packet, err := encoder.Encode(pcm)
+ if err != nil {
+ t.Fatalf("encode %d bps block %d: %v", bitrate, block, err)
+ }
+ // Opus RTP cannot fragment a frame. Reserve RTP/extensions/SRTP space.
+ if len(packet)+48 > 1200 {
+ t.Fatalf("%d bps block %d exceeded packet budget: %d payload bytes", bitrate, block, len(packet))
+ }
+ count, err := decoder.DecodeInt16(packet, decoded)
+ if err != nil || count != FrameSamples {
+ t.Fatalf("decode %d bps block %d: %d samples, %v", bitrate, block, count, err)
+ }
+ }
+ }
+}
diff --git a/internal/app/nativecapture/protocol.go b/internal/app/nativecapture/protocol.go
index 5ba4f96a4..f9d3bbe63 100644
--- a/internal/app/nativecapture/protocol.go
+++ b/internal/app/nativecapture/protocol.go
@@ -11,18 +11,53 @@ import (
)
const (
- envelopeHeaderBytes = 32
- maxMediaBytes = encoded.MaxAccessUnitBytes
- maxStatusBytes = 4 * 1024
- maxControlBytes = 64
- MaxVideoWidth = 2560
- MaxVideoHeight = 1440
+ envelopeHeaderBytes = 32
+ maxMediaBytes = encoded.MaxAccessUnitBytes
+ maxStatusBytes = 4 * 1024
+ maxControlBytes = 64
+ MaxVideoDimension = 8192
+ MaxVideoPixels = 36_864 * 16 * 16
+ MaxVideoMacroblocksPerSecond = 2_073_600
+ MaxVideoFramerate = 240 // Bound capture scheduling independently of codec throughput.
+ // Coded pictures may include alignment outside the visible crop. Keep this
+ // separate from visible dimensions and within H.264 Level 5.2 storage.
+ MaxCodedVideoDimension = 8192
+ MaxCodedVideoPixels = 36_864 * 16 * 16
)
// ValidVideoSize bounds the decoded picture in the current capture contract.
func ValidVideoSize(width, height uint32) bool {
- return width >= 2 && width <= MaxVideoWidth && width%2 == 0 &&
- height >= 2 && height <= MaxVideoHeight && height%2 == 0
+ return width >= 2 && width <= MaxVideoDimension && width%2 == 0 &&
+ height >= 2 && height <= MaxVideoDimension && height%2 == 0 &&
+ uint64((width+15)/16)*uint64((height+15)/16)*256 <= MaxVideoPixels
+}
+
+// ValidVideoFormat keeps both current encoders within the shared receive
+// ceiling (H.264 Level 5.2), including non-preset shapes and frame rates.
+func ValidVideoFormat(width, height, framerate uint32) bool {
+ return ValidVideoSize(width, height) && framerate >= 1 && framerate <= MaxVideoFramerate &&
+ uint64((width+15)/16)*uint64((height+15)/16)*uint64(framerate) <= MaxVideoMacroblocksPerSecond
+}
+
+func ValidCodedVideoSize(width, height uint64) bool {
+ return width >= 2 && width <= MaxCodedVideoDimension && width%2 == 0 &&
+ height >= 2 && height <= MaxCodedVideoDimension && height%2 == 0 &&
+ width*height <= MaxCodedVideoPixels
+}
+
+// SupportedH264ProfileLevelID bounds capture and receive to the same coding
+// tools. Automatic encoders may select a lower level for a smaller output.
+func SupportedH264ProfileLevelID(value string) bool {
+ level, valid := encoded.H264ConstrainedBaselineLevel(value)
+ if !valid {
+ return false
+ }
+ switch level {
+ case 10, 11, 12, 13, 20, 21, 22, 30, 31, 32, 40, 41, 42, 50, 51, 52:
+ return true
+ default:
+ return false
+ }
}
type FrameKind uint8
diff --git a/internal/app/nativecapture/protocol_test.go b/internal/app/nativecapture/protocol_test.go
index d38628432..fee2f4f5b 100644
--- a/internal/app/nativecapture/protocol_test.go
+++ b/internal/app/nativecapture/protocol_test.go
@@ -286,19 +286,53 @@ func TestCaptureTargetIdentityAndAudioScope(t *testing.T) {
func TestVideoProfileUsesTheProductBounds(t *testing.T) {
for _, profile := range []VideoProfile{
+ {Width: 1280, Height: 720, Framerate: 1, Bitrate: 100_000, Preference: "balanced"},
+ {Width: 1280, Height: 720, Framerate: 5, Bitrate: 800_000, Preference: "balanced"},
+ {Width: 3840, Height: 2160, Framerate: 60, Bitrate: 50_000_000, Preference: "balanced"},
{Width: 854, Height: 480, Framerate: 15, Bitrate: 2_000_000, Preference: "maintain-resolution"},
{Width: 1280, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 60, Bitrate: 8_000_000, Preference: "maintain-framerate"},
{Width: 2560, Height: 1440, Framerate: 60, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 1920, Height: 1200, Framerate: 30, Bitrate: 5_000_000, Preference: "balanced"},
+ {Width: 720, Height: 1280, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 3840, Height: 2160, Framerate: 30, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 2160, Height: 3840, Framerate: 30, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 3840, Height: 2160, Framerate: 60, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 2160, Height: 3840, Framerate: 60, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 3000, Height: 2764, Framerate: 30, Bitrate: 12_000_000, Preference: "balanced"},
+ {Width: 1920, Height: 1080, Framerate: 240, Bitrate: 240_000_000, Preference: "balanced"},
+ {Width: 2560, Height: 1440, Framerate: 144, Bitrate: 100_000_000, Preference: "balanced"},
+ {Width: 5120, Height: 1440, Framerate: 60, Bitrate: 100_000_000, Preference: "balanced"},
+ {Width: 1440, Height: 5120, Framerate: 60, Bitrate: 100_000_000, Preference: "balanced"},
+ {Width: 4096, Height: 2304, Framerate: 56, Bitrate: 80_000_000, Preference: "balanced"},
+ {Width: 4, Height: 4, Framerate: 15, Bitrate: 2_000_000, Preference: "balanced"},
} {
if !profile.Valid() {
t.Fatalf("valid profile rejected: %+v", profile)
}
+ for _, output := range ScreenShareOutputs(profile) {
+ if !output.Valid() || output.Bitrate > profile.Bitrate {
+ t.Fatalf("valid source profile produced an invalid output: %+v", output)
+ }
+ }
}
for _, profile := range []VideoProfile{
- {Width: 1920, Height: 1200, Framerate: 30, Bitrate: 5_000_000, Preference: "balanced"},
- {Width: 1920, Height: 1080, Framerate: 14, Bitrate: 5_000_000, Preference: "balanced"},
- {Width: 1920, Height: 1080, Framerate: 30, Bitrate: 12_000_001, Preference: "balanced"},
+ {Width: 0, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 2, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 1280, Height: 2, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 1279, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 1280, Height: 719, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 8194, Height: 100, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 5120, Height: 2880, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 4096, Height: 2306, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 6562, Height: 1438, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 3840, Height: 2160, Framerate: 65, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 1920, Height: 1080, Framerate: 241, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 2160, Height: 3840, Framerate: 120, Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: ^uint32(0), Height: ^uint32(0), Framerate: ^uint32(0), Bitrate: 3_000_000, Preference: "balanced"},
+ {Width: 1920, Height: 1080, Framerate: 0, Bitrate: 5_000_000, Preference: "balanced"},
+ {Width: 1920, Height: 1080, Framerate: 30, Bitrate: 99_999, Preference: "balanced"},
+ {Width: 1920, Height: 1080, Framerate: 30, Bitrate: 240_000_001, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 30, Bitrate: 5_000_000, Preference: "unknown"},
} {
if profile.Valid() {
diff --git a/internal/app/nativecapture/stream.go b/internal/app/nativecapture/stream.go
index 0df559bb3..38e96de5c 100644
--- a/internal/app/nativecapture/stream.go
+++ b/internal/app/nativecapture/stream.go
@@ -23,8 +23,8 @@ const (
captureStopTimeout = time.Second
maxPreviewBytes = 192 * 1024
maxOutputs = 6
- minOutputBitrate = 1_000 // Codec rate APIs use whole kbps.
- maxOutputBitrate = 12_000_000
+ minOutputBitrate = 1_000 // Codec rate APIs use whole kbps.
+ MaxVideoBitrate = 240_000_000 // H.264 Level 5.2 Constrained Baseline video bitrate.
maxSources = 1024
maxSourceTitleBytes = 4096
// Leave room for the control envelope within loopback's 256 KiB message.
@@ -88,9 +88,8 @@ type OutputProfile struct {
}
func (profile OutputProfile) Valid() bool {
- return ValidVideoSize(profile.Width, profile.Height) &&
- profile.Framerate >= 1 && profile.Framerate <= 60 &&
- profile.Bitrate >= minOutputBitrate && profile.Bitrate <= maxOutputBitrate
+ return ValidVideoFormat(profile.Width, profile.Height, profile.Framerate) &&
+ profile.Bitrate >= minOutputBitrate && profile.Bitrate <= MaxVideoBitrate
}
// ScreenShareOutputs follows LiveKit 2.22.1's screen-share simulcast construction,
@@ -99,7 +98,7 @@ func ScreenShareOutputs(profile VideoProfile) []OutputProfile {
return []OutputProfile{
{
Width: profile.Width / 4 * 2, Height: profile.Height / 4 * 2,
- Framerate: profile.Framerate, Bitrate: max(150_000, profile.Bitrate/4),
+ Framerate: profile.Framerate, Bitrate: min(profile.Bitrate, max(150_000, profile.Bitrate/4)),
},
{
Width: profile.Width, Height: profile.Height,
@@ -109,13 +108,10 @@ func ScreenShareOutputs(profile VideoProfile) []OutputProfile {
}
func (profile VideoProfile) Valid() bool {
- validResolution :=
- (profile.Width == 854 && profile.Height == 480) ||
- (profile.Width == 1280 && profile.Height == 720) ||
- (profile.Width == 1920 && profile.Height == 1080) ||
- (profile.Width == 2560 && profile.Height == 1440)
- return validResolution && profile.Framerate >= 15 && profile.Framerate <= 60 &&
- profile.Bitrate >= 2_000_000 && profile.Bitrate <= 12_000_000 &&
+ // The source ceiling must also yield a valid half-size output. Preset labels
+ // belong to the UI, not the capture process's numeric resource boundary.
+ return ValidVideoFormat(profile.Width, profile.Height, profile.Framerate) && profile.Width >= 4 && profile.Height >= 4 &&
+ profile.Bitrate >= 100_000 && profile.Bitrate <= MaxVideoBitrate &&
validVideoPreference(profile.Preference)
}
diff --git a/internal/app/nativecontrol/operation_failure_test.go b/internal/app/nativecontrol/operation_failure_test.go
index 490f88cae..e4f2b3b19 100644
--- a/internal/app/nativecontrol/operation_failure_test.go
+++ b/internal/app/nativecontrol/operation_failure_test.go
@@ -62,7 +62,7 @@ func TestSourceProbesRetireWithTheirControlConnection(t *testing.T) {
t.Fatal(err)
}
defer connection.CloseNow()
- if err = wsjson.Write(ctx, connection, json.RawMessage(`{"version":9,"id":"request_hello","type":"hello"}`)); err != nil {
+ if err = wsjson.Write(ctx, connection, json.RawMessage(`{"version":10,"id":"request_hello","type":"hello"}`)); err != nil {
t.Fatal(err)
}
var ready struct{ Type string }
@@ -78,7 +78,7 @@ func TestSourceProbesRetireWithTheirControlConnection(t *testing.T) {
t.Error("native probe fixture did not retire")
}
}()
- payload := fmt.Sprintf(`{"version":9,"id":"request_probe","type":%q%s}`, request.kind, request.fields)
+ payload := fmt.Sprintf(`{"version":10,"id":"request_probe","type":%q%s}`, request.kind, request.fields)
if err = wsjson.Write(ctx, connection, json.RawMessage(payload)); err != nil {
t.Fatal(err)
}
@@ -138,7 +138,7 @@ func TestOperationalFailureAndIdempotentStopKeepTheRealControlConnection(t *test
defer connection.CloseNow()
for index, command := range []struct{ kind, fields, expected string }{
{"hello", "", "ready"},
- {"start-share", `,"shareId":"missing_share","source":{"kind":"display","sourceId":"1","title":"Fixture"},"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}`, "request-failed"},
+ {"start-share", `,"shareId":"missing_share","source":{"kind":"display","sourceId":"1","title":"Fixture"},"edgeCapacity":1,"codec":"vp8","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}`, "request-failed"},
{"prepare-edge", `,"shareId":"missing_share","connectionId":"edge_123456","iceServers":[]`, "request-failed"},
{"edge-answer", `,"shareId":"missing_share","connectionId":"edge_123456","sdp":"v=0"`, "request-failed"},
{"receive-candidate", `,"shareId":"missing_share","connectionId":"edge_123456","candidate":null`, "request-failed"},
@@ -150,7 +150,7 @@ func TestOperationalFailureAndIdempotentStopKeepTheRealControlConnection(t *test
{"ping", "", "pong"},
} {
id := fmt.Sprintf("request_%d", index)
- payload := fmt.Sprintf(`{"version":9,"id":%q,"type":%q%s}`, id, command.kind, command.fields)
+ payload := fmt.Sprintf(`{"version":10,"id":%q,"type":%q%s}`, id, command.kind, command.fields)
if err := wsjson.Write(ctx, connection, json.RawMessage(payload)); err != nil {
t.Fatal(err)
}
@@ -164,7 +164,7 @@ func TestOperationalFailureAndIdempotentStopKeepTheRealControlConnection(t *test
}
}
// A gone target must not bypass strict publication validation.
- if err := wsjson.Write(ctx, connection, json.RawMessage(`{"version":9,"id":"request_invalid","type":"close-publication","shareId":"missing_share","publicationGeneration":"publication_123","connectionId":"edge_123456","extra":true}`)); err != nil {
+ if err := wsjson.Write(ctx, connection, json.RawMessage(`{"version":10,"id":"request_invalid","type":"close-publication","shareId":"missing_share","publicationGeneration":"publication_123","connectionId":"edge_123456","extra":true}`)); err != nil {
t.Fatal(err)
}
if _, _, err := connection.Read(ctx); websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
@@ -180,7 +180,7 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
t.Setenv("PIIK_QUIET_CAPTURE_FIXTURE", t.TempDir())
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"}}`
+ const start = `{"version":10,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"edgeCapacity":1,"codec":"vp8","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
if value, err := awaitControlResponse(t, session, []byte(start)); err != nil {
t.Fatal(err)
} else if _, ok := value.(shareStartedResponse); !ok {
@@ -190,14 +190,14 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
if host == nil {
t.Fatal("share was not installed")
}
- const edge = `{"version":9,"id":"request_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_123456"}`
+ const edge = `{"version":10,"id":"request_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_123456"}`
if value, err := session.Handle(t.Context(), []byte(edge)); err != nil {
t.Fatal(err)
} else if _, ok := value.(edgeOfferResponse); !ok {
t.Fatalf("edge preparation failed: %#v", value)
}
assertOperationFailure(t, session, edge)
- assertOperationFailure(t, session, `{"version":9,"id":"request_edge","type":"prepare-edge","shareId":"stale_share","connectionId":"edge_123456","iceServers":[]}`)
+ assertOperationFailure(t, session, `{"version":10,"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
@@ -209,7 +209,7 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
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)
+ payload := fmt.Sprintf(`{"version":10,"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 {
@@ -233,8 +233,8 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
t.Fatal(err)
}
for range 2 {
- value, err := session.Handle(t.Context(), []byte(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
- if err != nil || value != response(requestEnvelope{Version: 9, ID: "request_stop"}, "share-stopped") {
+ value, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
+ if err != nil || value != response(requestEnvelope{Version: 10, ID: "request_stop"}, "share-stopped") {
t.Fatalf("late stop: %#v, %v", value, err)
}
}
@@ -274,15 +274,15 @@ func TestCaptureStartupAndReplacementRemainCancellable(t *testing.T) {
}
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)
+ start := `{"version":10,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"edgeCapacity":1,"codec":"vp8","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
+ payload := strings.Replace(start, `"width":1920,"height":1080`, `"width":1280,"height":720`, 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}`
+ payload = `{"version":10,"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 {
@@ -300,12 +300,12 @@ func TestCaptureStartupAndReplacementRemainCancellable(t *testing.T) {
}
// 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 {
+ if _, err := session.Handle(t.Context(), []byte(`{"version":10,"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"}`))
+ _, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
stopped <- err
}()
select {
diff --git a/internal/app/nativecontrol/punch.go b/internal/app/nativecontrol/punch.go
new file mode 100644
index 000000000..ef52ac527
--- /dev/null
+++ b/internal/app/nativecontrol/punch.go
@@ -0,0 +1,150 @@
+package nativecontrol
+
+import (
+ "encoding/hex"
+ "errors"
+
+ "github.com/TNTcraftHIM/Piik/internal/app/mediaedge"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+type preparePunchRequest struct {
+ requestEnvelope
+ ShareID string `json:"shareId"`
+ ConnectionID string `json:"connectionId"`
+ SourceConnectionID string `json:"sourceConnectionId,omitempty"`
+ EdgeCapacity *int `json:"edgeCapacity,omitempty"`
+ ICEServers []iceServer `json:"iceServers"`
+ Secret string `json:"secret"`
+}
+
+type runPunchRequest struct {
+ requestEnvelope
+ ShareID string `json:"shareId"`
+ ConnectionID string `json:"connectionId"`
+ Prediction protocol.PunchPrediction `json:"prediction"`
+}
+
+type punchPreparedResponse struct {
+ responseEnvelope
+ Prediction *mediaedge.PunchPrediction `json:"prediction"`
+}
+
+type punchCompleteResponse struct {
+ responseEnvelope
+ Connected bool `json:"connected"`
+}
+
+func (session *Session) handlePunch(envelope requestEnvelope, payload []byte, complete func(any, error)) (value any, returnedErr error) {
+ var done chan struct{}
+ defer func() {
+ if done != nil && returnedErr != nil {
+ session.mu.Lock()
+ session.punchDone = nil
+ close(done)
+ session.mu.Unlock()
+ }
+ }()
+ switch envelope.Type {
+ case "prepare-punch":
+ var request preparePunchRequest
+ if err := decodeStrict(payload, &request); err != nil || !validIdentities(request.ShareID, request.ConnectionID) ||
+ (request.SourceConnectionID != "" && !validIdentities(request.SourceConnectionID)) ||
+ (request.EdgeCapacity != nil && (*request.EdgeCapacity < 1 || *request.EdgeCapacity > maxEdgeCapacity || request.SourceConnectionID != "")) {
+ return nil, protocolViolation("native connection preparation is invalid")
+ }
+ key, err := hex.DecodeString(request.Secret)
+ if err != nil || len(key) != 32 {
+ return nil, protocolViolation("native connection preparation key is invalid")
+ }
+ servers, err := pionICEServers(request.ICEServers)
+ if err != nil {
+ return nil, err
+ }
+ done, err = session.beginPunchOperation()
+ if err != nil {
+ return nil, err
+ }
+ var preparation *mediaedge.PunchPreparation
+ if request.EdgeCapacity != nil {
+ // Only a receiving request may establish a Viewer role. A late sender
+ // request must not replace its retired Host/relay source with a Viewer.
+ viewer, viewerErr := session.ensureViewer(request.ShareID, *request.EdgeCapacity)
+ if viewerErr != nil {
+ return nil, viewerErr
+ }
+ preparation, err = viewer.BeginPunch(request.ConnectionID, "", servers, request.Secret)
+ } else if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" {
+ preparation, err = host.BeginPunch(request.ConnectionID, servers, request.Secret)
+ } else if viewer := session.currentViewer(request.ShareID); viewer != nil && request.SourceConnectionID != "" {
+ preparation, err = viewer.BeginPunch(request.ConnectionID, request.SourceConnectionID, servers, request.Secret)
+ } else {
+ return nil, errors.New("native media source does not exist")
+ }
+ if err != nil {
+ return nil, err
+ }
+ // Reserve before launching work, so a subsequent close cannot be undone
+ // by a late worker. This is independent of the capture mutation slot.
+ go func() {
+ prediction, err := preparation.Prepare()
+ result := punchPreparedResponse{response(envelope, "punch-prepared"), prediction}
+ complete(result, err)
+ session.mu.Lock()
+ session.finishOperation(&session.punchDone, done, result)
+ }()
+ return nil, nil
+ case "run-punch":
+ var request runPunchRequest
+ if err := decodeStrict(payload, &request); err != nil || !validIdentities(request.ShareID, request.ConnectionID) {
+ return nil, protocolViolation("native connection probe is invalid")
+ }
+ prediction := mediaedge.PunchPrediction(request.Prediction)
+ if _, err := prediction.Target(); err != nil {
+ return nil, err
+ }
+ media := session.currentMedia(request.ShareID)
+ if media == nil {
+ return nil, errors.New("native media session does not exist")
+ }
+ preparation := media.Punch(request.ConnectionID)
+ if preparation == nil {
+ return nil, errors.New("connection preparation was retired")
+ }
+ var err error
+ done, err = session.beginPunchOperation()
+ if err != nil {
+ return nil, err
+ }
+ go func() {
+ result := punchCompleteResponse{response(envelope, "punch-complete"), preparation.Run(prediction)}
+ complete(result, nil)
+ session.mu.Lock()
+ session.finishOperation(&session.punchDone, done, result)
+ }()
+ return nil, nil
+ case "close-punch":
+ var request closeEdgeRequest
+ if err := decodeStrict(payload, &request); err != nil || !validIdentities(request.ShareID, request.ConnectionID) {
+ return nil, protocolViolation("native connection retirement is invalid")
+ }
+ if media := session.currentMedia(request.ShareID); media != nil {
+ media.ClosePunch(request.ConnectionID)
+ }
+ return response(envelope, "punch-closed"), nil
+ }
+ return nil, protocolViolation("unknown connection preparation request")
+}
+
+// Bound response workers as well as socket work. Keep this slot through response
+// delivery, independently of capture mutations; close-punch remains synchronous.
+func (session *Session) beginPunchOperation() (chan struct{}, error) {
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ if session.closed || session.punchDone != nil {
+ return nil, errors.New("connection preparation is unavailable or already active")
+ }
+ done := make(chan struct{})
+ session.punchDone = done
+ return done, nil
+}
diff --git a/internal/app/nativecontrol/punch_test.go b/internal/app/nativecontrol/punch_test.go
new file mode 100644
index 000000000..57c90654c
--- /dev/null
+++ b/internal/app/nativecontrol/punch_test.go
@@ -0,0 +1,129 @@
+package nativecontrol
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/app/loopback"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
+)
+
+func TestPunchResponseBackpressureBoundsWorkers(t *testing.T) {
+ session := New("", nativecapture.Capabilities{}, false)
+ t.Cleanup(func() { _ = session.Close() })
+ viewer, err := session.ensureViewer("review_share", 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := viewer.BeginPunch("review_connection", "", nil, strings.Repeat("42", 32)); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < cap(session.events); i++ {
+ session.events <- struct{}{}
+ }
+ const run = `{"version":10,"id":"run_request","type":"run-punch","shareId":"review_share","connectionId":"review_connection","prediction":{"address":"198.51.100.1","port":5000,"step":0}}`
+ if value, err := session.Handle(t.Context(), []byte(run)); err != nil || value != nil {
+ t.Fatalf("first probe was not admitted: %#v, %v", value, err)
+ }
+ session.mu.Lock()
+ done := session.punchDone
+ session.mu.Unlock()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("ineligible probe did not finish")
+ }
+ for i := 0; i < 512; i++ {
+ value, err := session.Handle(t.Context(), []byte(run))
+ if _, failed := value.(requestFailedResponse); err != nil || !failed {
+ t.Fatalf("duplicate probe escaped response admission: %#v, %v", value, err)
+ }
+ }
+ if _, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"close_request","type":"close-punch","shareId":"review_share","connectionId":"review_connection"}`)); err != nil {
+ t.Fatal(err)
+ }
+ if viewer.Punch("review_connection") != nil {
+ t.Fatal("response backpressure blocked preparation retirement")
+ }
+ // Draining the response permits the next phase/attempt; it is not a session failure.
+ for i := 0; i < cap(session.events); i++ {
+ <-session.events
+ }
+ var completion loopback.ControlEvent
+ select {
+ case event := <-session.events:
+ completion = event.(loopback.ControlEvent)
+ deliveredControlValue(completion)
+ case <-time.After(time.Second):
+ t.Fatal("probe completion was not delivered")
+ }
+ if value, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"prepare_request","type":"prepare-punch","shareId":"review_share","connectionId":"next_connection","iceServers":[],"secret":"`+strings.Repeat("42", 32)+`","edgeCapacity":1}`)); err != nil || value != nil {
+ t.Fatalf("next preparation was not admitted: %#v, %v", value, err)
+ }
+ completion.BeforeWrite()
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ if session.punchDone == nil {
+ t.Fatal("old completion released its successor's admission")
+ }
+}
+
+func TestPunchPreparationPreservesMediaRole(t *testing.T) {
+ for _, sourceID := range []string{"", "retired_receiver"} {
+ t.Run("sender/"+sourceID, func(t *testing.T) {
+ session := New("", nativecapture.Capabilities{}, false)
+ t.Cleanup(func() { _ = session.Close() })
+ request := preparePunchRequest{
+ requestEnvelope: requestEnvelope{Version: 10, ID: "prepare_request", Type: "prepare-punch"},
+ ShareID: "retired_share", ConnectionID: "new_connection", SourceConnectionID: sourceID,
+ Secret: strings.Repeat("42", 32),
+ }
+ payload, _ := json.Marshal(request)
+ value, err := session.Handle(t.Context(), payload)
+ if _, failed := value.(requestFailedResponse); err != nil || !failed {
+ t.Fatalf("missing sender must fail only its request: %#v, %v", value, err)
+ }
+ if session.currentViewer(request.ShareID) != nil {
+ t.Fatal("late sender preparation installed a Viewer role")
+ }
+ })
+ }
+ t.Run("receiver", func(t *testing.T) {
+ session := New("", nativecapture.Capabilities{}, false)
+ t.Cleanup(func() { _ = session.Close() })
+ capacity := 2
+ request := preparePunchRequest{
+ requestEnvelope: requestEnvelope{Version: 10, ID: "prepare_request", Type: "prepare-punch"},
+ ShareID: "receiving_share", ConnectionID: "new_connection", EdgeCapacity: &capacity,
+ Secret: strings.Repeat("42", 32),
+ }
+ payload, _ := json.Marshal(request)
+ if _, err := session.Handle(t.Context(), payload); err != nil {
+ t.Fatal(err)
+ }
+ if session.currentViewer(request.ShareID) == nil {
+ t.Fatal("explicit receiving preparation did not install its Viewer role")
+ }
+ })
+}
+
+func TestPunchRequiresExplicitMappingStep(t *testing.T) {
+ for _, step := range []string{"", `,"step":null`, `,"step":0`} {
+ t.Run(step, func(t *testing.T) {
+ session := New("", nativecapture.Capabilities{}, false)
+ t.Cleanup(func() { _ = session.Close() })
+ payload := []byte(`{"version":10,"id":"run_request","type":"run-punch","shareId":"share_id",` +
+ `"connectionId":"connection","prediction":{"address":"198.51.100.1","port":5000` + step + `}}`)
+ result, err := session.Handle(t.Context(), payload)
+ if step != `,"step":0` {
+ if err == nil {
+ t.Fatal("missing or null step accepted as a stable observation")
+ }
+ } else if _, failed := result.(requestFailedResponse); err != nil || !failed {
+ t.Fatalf("valid observation should reach the absent media session: %#v, %v", result, err)
+ }
+ })
+ }
+}
diff --git a/internal/app/nativecontrol/session.go b/internal/app/nativecontrol/session.go
index 40a59a71f..76bfa6c95 100644
--- a/internal/app/nativecontrol/session.go
+++ b/internal/app/nativecontrol/session.go
@@ -52,14 +52,17 @@ type Session struct {
viewer *nativeviewer.Session
closed bool
updateDone chan struct{}
+ punchDone chan struct{}
startID string
startCancel context.CancelFunc
}
type outboundMediaSession interface {
- SetAnswer(string, webrtc.SessionDescription) error
+ SetAnswer(string, webrtc.SessionDescription, string) error
AddCandidate(string, *webrtc.ICECandidateInit) error
CloseEdge(string)
+ Punch(string) *mediaedge.PunchPreparation
+ ClosePunch(string)
}
func New(
@@ -109,6 +112,10 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
}
}()
switch envelope.Type {
+ case "prepare-punch", "run-punch", "close-punch":
+ result, returnedErr = session.handlePunch(envelope, payload, complete)
+ asynchronous = result == nil && returnedErr == nil
+ return result, returnedErr
case "prepare-publication", "publication-media", "publication-answer", "publication-candidate", "publication-layers", "close-publication":
return session.handlePublication(envelope, payload)
case "capture-options":
@@ -178,10 +185,12 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
!identityPattern.MatchString(request.ShareID) ||
!request.Source.Valid() ||
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
- (request.Codec != "auto" && request.Codec != "h264" && request.Codec != "vp8") ||
- !validQualitySettings(request.Profile) {
+ (request.Codec != "auto" && request.Codec != "h264" && request.Codec != "vp8") {
return nil, protocolViolation("native start-share request is invalid")
}
+ if !nativeQualityProfile(request.Profile).Valid() {
+ return nil, errors.New("native sharing profile is unsupported")
+ }
if err := session.validateAudioExclusion(request.Source, request.Audio, request.ExcludeAudio); err != nil {
return nil, err
}
@@ -226,11 +235,13 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
var request updateShareRequest
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
- !validIdentities(request.ShareID) ||
- !validQualitySettings(request.Profile) {
+ !validIdentities(request.ShareID) {
return nil, protocolViolation("native update-share request is invalid")
}
profile := nativeQualityProfile(request.Profile)
+ if !profile.Valid() {
+ return nil, errors.New("native sharing profile is unsupported")
+ }
slog.Debug("piik-client", "event", "native-profile-requested", "requestId", envelope.ID,
"share", diagnostics.ID(request.ShareID), "profile", profile)
var err error
@@ -351,7 +362,8 @@ 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) {
+ !validIdentities(request.ShareID, request.ConnectionID) ||
+ (request.OfferID != "" && !identityPattern.MatchString(request.OfferID)) {
return nil, protocolViolation("native edge-answer request is invalid")
}
if !validSDP(request.SDP) {
@@ -364,6 +376,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := media.SetAnswer(
request.ConnectionID,
webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: request.SDP},
+ request.OfferID,
); err != nil {
return nil, err
}
@@ -487,7 +500,7 @@ func (session *Session) runHostOperation(host *nativehost.Session, request reque
result = operationFailure(request, err)
}
complete(result, err)
- session.finishHostOperation(done, result)
+ session.finishOperation(&session.updateDone, done, result)
}()
return nil
}
@@ -513,34 +526,28 @@ func (session *Session) beginShare(ctx context.Context, envelope requestEnvelope
complete(result, err)
session.mu.Lock()
session.startID, session.startCancel = "", nil
- session.finishHostOperation(done, result)
+ session.finishOperation(&session.updateDone, 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) {
+// Caller holds mu. Queue capacity and worker admission share the response
+// lifetime; the writer releases admission before the ACK can trigger a successor.
+func (session *Session) finishOperation(slot *chan struct{}, done chan struct{}, result any) {
close(done)
if session.closed {
- session.updateDone = nil
+ *slot = 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()
+ session.emit(loopback.ControlEvent{Value: result, BeforeWrite: func() {
+ session.mu.Lock()
+ if *slot == done {
+ *slot = nil
+ }
+ session.mu.Unlock()
+ }})
}
func (session *Session) validateAudioExclusion(source nativecapture.CaptureTarget, audio bool, excluded *nativecapture.CaptureTarget) error {
@@ -636,37 +643,23 @@ func (session *Session) startShare(
}, nil
}
-func validQualitySettings(settings qualitySettings) bool {
- return nativeQualityProfile(settings).Valid()
-}
-
func nativeQualityProfile(settings qualitySettings) nativehost.QualityProfile {
- width, height := uint32(0), uint32(0)
- switch settings.Resolution {
- case "480p":
- width, height = 854, 480
- case "720p":
- width, height = 1280, 720
- case "1080p":
- width, height = 1920, 1080
- case "1440p":
- width, height = 2560, 1440
- }
- audioBitrate := 0
- switch settings.ScreenAudioQuality {
- case "", "music":
- audioBitrate = 128_000
- case "saver":
- audioBitrate = 64_000
- case "very-high":
- audioBitrate = 192_000
+ // The wire can express more than this integer capture API. Never truncate a
+ // fractional rate or wrap a wider value into an apparently supported profile.
+ if settings.MaxFramerate < 1 || settings.MaxFramerate > 0xffff_ffff ||
+ settings.MaxFramerate != protocol.Num(uint32(settings.MaxFramerate)) {
+ return nativehost.QualityProfile{}
+ }
+ audioBitrate := int(settings.MaxAudioBitrate)
+ if audioBitrate == 0 {
+ audioBitrate = int(protocol.DefaultQualitySettings.MaxAudioBitrate)
}
return nativehost.QualityProfile{
Video: nativecapture.VideoProfile{
- Width: width,
- Height: height,
- Framerate: settings.MaxFramerate,
- Bitrate: settings.MaxBitrate,
+ Width: uint32(settings.Resolution.Width),
+ Height: uint32(settings.Resolution.Height),
+ Framerate: uint32(settings.MaxFramerate),
+ Bitrate: uint32(settings.MaxBitrate),
Preference: settings.DegradationPreference,
},
AudioBitrate: audioBitrate,
@@ -683,13 +676,14 @@ func (session *Session) prepareEdge(
}
var offer webrtc.SessionDescription
if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" {
- offer, err = host.PrepareEdge(request.ConnectionID, servers)
+ offer, err = host.PrepareEdge(request.ConnectionID, servers, request.Punched)
} else if viewer := session.currentViewer(request.ShareID); viewer != nil &&
identityPattern.MatchString(request.SourceConnectionID) {
offer, err = viewer.PrepareEdge(
request.SourceConnectionID,
request.ConnectionID,
servers,
+ request.Punched,
)
} else {
return nil, errors.New("native media source does not exist")
@@ -712,12 +706,13 @@ func (session *Session) prepareLocalEdge(
var offer webrtc.SessionDescription
var err error
if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" {
- offer, err = host.PrepareLocalEdge(request.ConnectionID)
+ offer, err = host.PrepareLocalEdge(request.ConnectionID, envelope.ID, request.Restart)
} else if viewer := session.currentViewer(request.ShareID); viewer != nil &&
identityPattern.MatchString(request.SourceConnectionID) {
offer, err = viewer.PrepareLocalEdge(
request.SourceConnectionID,
request.ConnectionID,
+ envelope.ID, request.Restart,
)
} else {
return nil, errors.New("native media source does not exist")
@@ -750,6 +745,7 @@ func (session *Session) receiveOffer(
webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.SDP},
servers,
request.ReuseReceiver,
+ request.Punched,
)
if err != nil {
return nil, err
diff --git a/internal/app/nativecontrol/session_test.go b/internal/app/nativecontrol/session_test.go
index d626d45c2..222a304b6 100644
--- a/internal/app/nativecontrol/session_test.go
+++ b/internal/app/nativecontrol/session_test.go
@@ -18,11 +18,25 @@ import (
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
"github.com/TNTcraftHIM/Piik/internal/app/nativehost"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
"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 deliveredControlValue(value any) any {
+ if event, ok := value.(loopback.ControlEvent); ok {
+ if event.Current != nil && !event.Current() {
+ return nil
+ }
+ if event.BeforeWrite != nil {
+ event.BeforeWrite()
+ }
+ return event.Value
+ }
+ return value
+}
+
func awaitControlResponse(t *testing.T, session *Session, payload []byte) (any, error) {
t.Helper()
value, err := session.Handle(t.Context(), payload)
@@ -38,6 +52,7 @@ func awaitControlResponse(t *testing.T, session *Session, payload []byte) (any,
for {
select {
case value := <-session.Events():
+ value = deliveredControlValue(value)
encoded, _ := json.Marshal(value)
var envelope responseEnvelope
_ = json.Unmarshal(encoded, &envelope)
@@ -67,7 +82,7 @@ func TestReceiveOfferReusePreservesLegacyResponseShape(t *testing.T) {
session := New("", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
for _, reuse := range []bool{false, true} {
- request := receiveOfferRequest{Version: 9, ID: "request_offer", Type: "receive-offer", ShareID: "viewer_session",
+ request := receiveOfferRequest{Version: 10, ID: "request_offer", Type: "receive-offer", ShareID: "viewer_session",
ConnectionID: "connection_1234", EdgeCapacity: 2, SDP: offer.SDP, ReuseReceiver: reuse}
payload, _ := json.Marshal(request)
value, err := session.Handle(t.Context(), payload)
@@ -108,6 +123,11 @@ func TestMain(tests *testing.M) {
runQuietCaptureFixture(directory)
os.Exit(0)
}
+ // Self-exec capture fixtures inherit this before their race runtime starts.
+ // Real native helpers have no race detector's default one-second exit sleep.
+ if err := os.Setenv("GORACE", os.Getenv("GORACE")+" atexit_sleep_ms=0"); err != nil {
+ panic(err)
+ }
os.Exit(tests.Run())
}
@@ -142,7 +162,7 @@ func TestSourceListFitsTheControlEnvelope(t *testing.T) {
session := New(executable, nativecapture.Capabilities{}, false)
defer session.Close()
value, err := awaitControlResponse(t, session,
- []byte(`{"version":9,"id":"request_sources","type":"list-sources"}`))
+ []byte(`{"version":10,"id":"request_sources","type":"list-sources"}`))
if err != nil {
t.Fatal(err)
}
@@ -250,10 +270,10 @@ func TestMicrophoneOptInPreservesLegacyAndOrdersStateBeforeCompletion(t *testing
}
session := New(executable, nativecapture.Capabilities{VideoCapture: true, SoftwareVP8: true, Microphone: true}, false)
defer session.Close()
- request := startShareRequest{Version: 9, ID: "request_start", Type: "start-share", ShareID: "share_123456",
+ request := startShareRequest{Version: 10, ID: "request_start", Type: "start-share", ShareID: "share_123456",
Source: nativecapture.CaptureTarget{Kind: "display", SourceID: "1", Title: "Fixture"},
MicrophoneMixing: optIn, EdgeCapacity: 1, Codec: "vp8",
- Profile: qualitySettings{Resolution: "1080p", MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}}
+ Profile: qualitySettings{Resolution: protocol.QualityResolution{Width: 1920, Height: 1080}, MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}}
payload, _ := json.Marshal(request)
result, err := awaitControlResponse(t, session, payload)
started, ok := result.(shareStartedResponse)
@@ -264,7 +284,7 @@ func TestMicrophoneOptInPreservesLegacyAndOrdersStateBeforeCompletion(t *testing
if started.Audio != optIn || bytes.Contains(encoded, []byte(`"sourceAudio"`)) != optIn {
t.Fatalf("mixed output leaked across opt-in: %s", encoded)
}
- result, err = session.Handle(t.Context(), []byte(`{"version":9,"id":"request_mic","type":"set-microphone","shareId":"share_123456","enabled":true,"volume":1}`))
+ result, err = session.Handle(t.Context(), []byte(`{"version":10,"id":"request_mic","type":"set-microphone","shareId":"share_123456","enabled":true,"volume":1}`))
if !optIn {
if _, failed := result.(requestFailedResponse); err != nil || !failed {
t.Fatalf("non-opted share admitted mic: %v %#v", err, result)
@@ -278,6 +298,7 @@ func TestMicrophoneOptInPreservesLegacyAndOrdersStateBeforeCompletion(t *testing
for {
select {
case event := <-session.Events():
+ event = deliveredControlValue(event)
switch value := event.(type) {
case audioStateEvent:
stateSeen = value.Microphone && !value.SourceAudio
@@ -332,15 +353,15 @@ func TestCaptureBorderPreferenceFollowsSourceAndProfile(t *testing.T) {
t.Fatalf("capture border intent lost: %v", arguments)
}
}
- start := startShareRequest{Version: 9, ID: "request_start", Type: "start-share", ShareID: "share_123456",
+ start := startShareRequest{Version: 10, ID: "request_start", Type: "start-share", ShareID: "share_123456",
Source: nativecapture.CaptureTarget{Kind: "display", SourceID: "1", Title: "Fixture"},
ShowCaptureBorder: true, EdgeCapacity: 1, Codec: "vp8",
- Profile: qualitySettings{Resolution: "1080p", MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}}
+ Profile: qualitySettings{Resolution: protocol.QualityResolution{Width: 1920, Height: 1080}, MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}}
handle(start)
assertBorder(supported)
profile := start.Profile
- profile.Resolution = "480p"
- handle(updateShareRequest{Version: 9, ID: "request_update", Type: "update-share", ShareID: start.ShareID, Profile: profile})
+ profile.Resolution = protocol.QualityResolution{Width: 854, Height: 480}
+ handle(updateShareRequest{Version: 10, ID: "request_update", Type: "update-share", ShareID: start.ShareID, Profile: profile})
session.mu.Lock()
done := session.updateDone
session.mu.Unlock()
@@ -353,11 +374,11 @@ func TestCaptureBorderPreferenceFollowsSourceAndProfile(t *testing.T) {
}
assertBorder(supported)
for _, show := range []bool{false, true} {
- handle(replaceShareSourceRequest{Version: 9, ID: "request_replace", Type: "replace-share-source", ShareID: start.ShareID,
+ handle(replaceShareSourceRequest{Version: 10, ID: "request_replace", Type: "replace-share-source", ShareID: start.ShareID,
Source: nativecapture.CaptureTarget{Kind: "display", SourceID: "2", Title: "Other fixture"}, ShowCaptureBorder: show})
assertBorder(show && supported)
}
- handle(stopShareRequest{Version: 9, ID: "request_stop", Type: "stop-share", ShareID: start.ShareID})
+ handle(stopShareRequest{Version: 10, ID: "request_stop", Type: "stop-share", ShareID: start.ShareID})
})
}
}
@@ -393,9 +414,9 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
}
return value
}
- const start = `{"version":9,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"adapterIndex":0,"encoderIndex":0,"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
+ const start = `{"version":10,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"adapterIndex":0,"encoderIndex":0,"edgeCapacity":1,"codec":"vp8","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
handle(start)
- const update = `{"version":9,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"720p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced"}}`
+ const update = `{"version":10,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":{"width":1280,"height":720},"maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced"}}`
if value := handle(update); value != nil {
t.Fatalf("Host update did not defer: %#v", value)
}
@@ -414,15 +435,15 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
}
for _, payload := range []string{
strings.Replace(update, "request_update", "request_duplicate", 1),
- `{"version":9,"id":"request_replace","type":"replace-share-source","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"adapterIndex":0,"encoderIndex":0}`,
+ `{"version":10,"id":"request_replace","type":"replace-share-source","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"adapterIndex":0,"encoderIndex":0}`,
} {
if _, ok := handle(payload).(requestFailedResponse); !ok {
t.Fatal("conflicting capture mutation was not rejected")
}
}
for _, payload := range []string{
- `{"version":9,"id":"request_pause","type":"pause-share","shareId":"share_123456","paused":true}`,
- `{"version":9,"id":"request_ice","type":"edge-candidate","shareId":"share_123456","connectionId":"retired_edge","candidate":null}`,
+ `{"version":10,"id":"request_pause","type":"pause-share","shareId":"share_123456","paused":true}`,
+ `{"version":10,"id":"request_ice","type":"edge-candidate","shareId":"share_123456","connectionId":"retired_edge","candidate":null}`,
} {
started := time.Now()
handle(payload)
@@ -457,7 +478,7 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
t.Fatal(err)
}
case "stop":
- handle(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`)
+ handle(`{"version":10,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`)
case "disconnect":
if err = session.Close(); err != nil {
t.Fatal(err)
@@ -474,7 +495,7 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
if outcome == "backpressure-stop" {
stopped := make(chan any, 1)
go func() {
- value, err := session.Handle(t.Context(), []byte(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
+ value, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
if err != nil {
stopped <- err
} else {
@@ -497,6 +518,7 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
for {
select {
case value := <-session.Events():
+ value = deliveredControlValue(value)
if response, ok := value.(shareUpdatedResponse); ok && response.ID == id {
return value
}
@@ -577,7 +599,7 @@ func TestControlMessagesRejectUnknownFieldsAndStaleVersions(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
for _, payload := range []string{
- `{"version":9,"id":"request_sources","type":"list-sources","extra":true}`,
+ `{"version":10,"id":"request_sources","type":"list-sources","extra":true}`,
`{"version":4,"id":"request_sources","type":"list-sources"}`,
`{"version":5,"id":"short","type":"stop-share","shareId":"share_123456"}`,
} {
@@ -609,9 +631,9 @@ func TestResponseKeepsTheRequestIdentity(t *testing.T) {
func TestPrepareLocalEdgeOwnsOneStrictRequestShape(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
- valid := `{"version":9,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567"}`
+ valid := `{"version":10,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567"}`
assertOperationFailure(t, session, valid)
- invalid := `{"version":9,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567","iceServers":[]}`
+ invalid := `{"version":10,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567","iceServers":[]}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil || err.Error() != "native prepare-local-edge request is invalid" {
t.Fatalf("extended local-edge request was accepted: %v", err)
}
@@ -621,7 +643,7 @@ func TestPreviewFailureReturnsAnAdvisoryResponse(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
value, err := session.Handle(t.Context(), []byte(
- `{"version":9,"id":"request_preview","type":"source-preview","source":{"kind":"display","sourceId":"65537","title":"Display 1"}}`,
+ `{"version":10,"id":"request_preview","type":"source-preview","source":{"kind":"display","sourceId":"65537","title":"Display 1"}}`,
))
if err != nil {
t.Fatal(err)
@@ -636,7 +658,7 @@ func TestSourceEnumerationFailureDoesNotCloseControl(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
value, err := session.Handle(t.Context(), []byte(
- `{"version":9,"id":"request_sources","type":"list-sources"}`,
+ `{"version":10,"id":"request_sources","type":"list-sources"}`,
))
if err != nil || value != operationFailure(requestEnvelope{
Version: loopback.ProtocolVersion, ID: "request_sources", Type: "list-sources",
@@ -644,7 +666,7 @@ func TestSourceEnumerationFailureDoesNotCloseControl(t *testing.T) {
t.Fatalf("source list failure ended its control session: %#v, %v", value, err)
}
if _, err := session.Handle(t.Context(), []byte(
- `{"version":9,"id":"request_options","type":"capture-options"}`,
+ `{"version":10,"id":"request_options","type":"capture-options"}`,
)); err != nil {
t.Fatalf("control was unusable after source enumeration failed: %v", err)
}
@@ -652,30 +674,61 @@ func TestSourceEnumerationFailureDoesNotCloseControl(t *testing.T) {
func TestQualitySettingsMapOnceIntoNativeMedia(t *testing.T) {
settings := qualitySettings{
- Resolution: "1440p", MaxFramerate: 60, MaxBitrate: 12_000_000,
+ Resolution: protocol.QualityResolution{Width: 2560, Height: 1440}, MaxFramerate: 60, MaxBitrate: 12_000_000,
DegradationPreference: "maintain-framerate",
- ScreenAudioQuality: "very-high",
+ MaxAudioBitrate: 192000,
}
profile := nativeQualityProfile(settings)
- if !validQualitySettings(settings) || profile.Video.Width != 2560 ||
+ if !profile.Valid() || profile.Video.Width != 2560 ||
profile.Video.Height != 1440 || profile.Video.Framerate != 60 ||
profile.Video.Bitrate != 12_000_000 ||
profile.Video.Preference != "maintain-framerate" ||
profile.AudioBitrate != 192_000 {
t.Fatalf("native profile = %+v", profile)
}
- settings.Resolution = "2160p"
- if validQualitySettings(settings) {
- t.Fatal("unsupported resolution was accepted")
+ settings.Resolution = protocol.QualityResolution{Width: 1920, Height: 1200}
+ if profile := nativeQualityProfile(settings); !profile.Valid() || profile.Video.Height != 1200 {
+ t.Fatalf("numeric size outside UI presets was not preserved: %+v", profile)
+ }
+ settings.Resolution = protocol.QualityResolution{Width: 3840, Height: 2160}
+ if !nativeQualityProfile(settings).Valid() {
+ t.Fatal("4K60 was rejected")
+ }
+ settings.MaxFramerate = 30
+ if !nativeQualityProfile(settings).Valid() {
+ t.Fatal("4K30 was rejected")
+ }
+ for _, fps := range []protocol.Num{30.5, 65, 120, 0x1_0000_0000 + 30} {
+ settings.MaxFramerate = fps
+ if nativeQualityProfile(settings).Valid() {
+ t.Fatalf("unsupported fps %v was narrowed into a valid profile", fps)
+ }
}
}
func TestUpdateShareRequiresTheCurrentStrictProfile(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
- valid := `{"version":9,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"}}`
+ valid := `{"version":10,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","maxAudioBitrate":128000}}`
assertOperationFailure(t, session, valid)
- invalid := `{"version":9,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music","extra":true}}`
+ // Opus permits this numeric request, but the current 20 ms sender does not.
+ // Keep the control session usable and let the caller keep its current share.
+ assertOperationFailure(t, session, strings.Replace(valid, `"maxAudioBitrate":128000`, `"maxAudioBitrate":510000`, 1))
+ assertOperationFailure(t, session, strings.Replace(valid,
+ `"resolution":{"width":1920,"height":1080}`,
+ `"resolution":{"width":7680,"height":4320}`, 1))
+ for _, replace := range [][2]string{
+ {`"width":1920`, `"width":32768`}, {`"width":1920`, `"width":1921`},
+ {`"maxFramerate":30`, `"maxFramerate":480`}, {`"maxFramerate":30`, `"maxFramerate":30.5`},
+ {`"maxFramerate":30`, `"maxFramerate":4294967326`}, {`"maxFramerate":30`, `"maxFramerate":0.5`},
+ {`"maxBitrate":5000000`, `"maxBitrate":240000001`}, {`"maxBitrate":5000000`, `"maxBitrate":1000`},
+ } {
+ assertOperationFailure(t, session, strings.Replace(valid, replace[0], replace[1], 1))
+ }
+ if _, err := session.Handle(t.Context(), []byte(`{"version":10,"id":"request_options","type":"capture-options"}`)); err != nil {
+ t.Fatalf("unsupported media setting retired control: %v", err)
+ }
+ invalid := `{"version":10,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":{"width":1920,"height":1080},"maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","maxAudioBitrate":128000,"extra":true}}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil ||
err.Error() != "native update-share request is invalid" {
t.Fatalf("extended update was accepted: %v", err)
@@ -685,9 +738,9 @@ func TestUpdateShareRequiresTheCurrentStrictProfile(t *testing.T) {
func TestReplaceShareSourceKeepsOneStrictTargetShape(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
- valid := `{"version":9,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0}`
+ valid := `{"version":10,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0}`
assertOperationFailure(t, session, valid)
- invalid := `{"version":9,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0,"extra":true}`
+ invalid := `{"version":10,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0,"extra":true}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil ||
err.Error() != "native replace-share-source request is invalid" {
t.Fatalf("extended source replacement was accepted: %v", err)
diff --git a/internal/app/nativecontrol/wire.go b/internal/app/nativecontrol/wire.go
index b69ab1ca6..6efd759f7 100644
--- a/internal/app/nativecontrol/wire.go
+++ b/internal/app/nativecontrol/wire.go
@@ -3,6 +3,7 @@ package nativecontrol
import (
"github.com/TNTcraftHIM/Piik/internal/app/mediaedge"
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
"github.com/pion/webrtc/v4"
)
@@ -33,13 +34,7 @@ type sourcePreviewRequest struct {
Source nativecapture.CaptureTarget `json:"source"`
}
-type qualitySettings struct {
- Resolution string `json:"resolution"`
- MaxFramerate uint32 `json:"maxFramerate"`
- MaxBitrate uint32 `json:"maxBitrate"`
- DegradationPreference string `json:"degradationPreference"`
- ScreenAudioQuality string `json:"screenAudioQuality,omitempty"`
-}
+type qualitySettings = protocol.QualitySettings
type startShareRequest struct {
Version int `json:"version"`
@@ -121,6 +116,7 @@ type prepareEdgeRequest struct {
ConnectionID string `json:"connectionId"`
SourceConnectionID string `json:"sourceConnectionId,omitempty"`
ICEServers []iceServer `json:"iceServers"`
+ Punched bool `json:"punched,omitempty"`
}
type prepareLocalEdgeRequest struct {
@@ -130,6 +126,7 @@ type prepareLocalEdgeRequest struct {
ShareID string `json:"shareId"`
ConnectionID string `json:"connectionId"`
SourceConnectionID string `json:"sourceConnectionId,omitempty"`
+ Restart bool `json:"restart"`
}
type receiveOfferRequest struct {
@@ -142,6 +139,7 @@ type receiveOfferRequest struct {
ICEServers []iceServer `json:"iceServers"`
SDP string `json:"sdp"`
ReuseReceiver bool `json:"reuseReceiver,omitempty"`
+ Punched bool `json:"punched,omitempty"`
}
type receiveCandidateRequest struct {
@@ -175,6 +173,7 @@ type edgeAnswerRequest struct {
ShareID string `json:"shareId"`
ConnectionID string `json:"connectionId"`
SDP string `json:"sdp"`
+ OfferID string `json:"offerId,omitempty"`
}
type edgeCandidateRequest struct {
diff --git a/internal/app/nativehost/audio_mix_test.go b/internal/app/nativehost/audio_mix_test.go
index 1e72e84c1..318963bdc 100644
--- a/internal/app/nativehost/audio_mix_test.go
+++ b/internal/app/nativehost/audio_mix_test.go
@@ -171,7 +171,7 @@ func TestAudioExclusionFailureMutesOnlySourceAndNeverFallsBack(t *testing.T) {
func TestSameVideoSourceAudioChangesPreserveCaptureAndConnections(t *testing.T) {
session, options, _ := startMixedFixture(t)
- if _, err := session.PrepareLocalEdge("audio_change"); err != nil {
+ if _, err := session.PrepareLocalEdge("audio_change", "local_offer_1", false); err != nil {
t.Fatal(err)
}
video, source, output, edge := session.stream, session.source, session.audioSource, session.edge("audio_change")
@@ -210,7 +210,7 @@ func TestNativeMicrophonePreservesMediaOwnersAcrossInputChanges(t *testing.T) {
if !session.HasAudio() || session.SourceAudio() == nil || *session.SourceAudio() {
t.Fatal("silent mixed output was not reserved independently of source")
}
- _, err := session.PrepareLocalEdge("mixed_edge")
+ _, err := session.PrepareLocalEdge("mixed_edge", "local_offer_1", false)
check(err)
edge, source := session.edge("mixed_edge"), session.audioSource
enabled, gain := true, 1.0
diff --git a/internal/app/nativehost/audio_test.go b/internal/app/nativehost/audio_test.go
index f2fc435ee..a51ed0966 100644
--- a/internal/app/nativehost/audio_test.go
+++ b/internal/app/nativehost/audio_test.go
@@ -147,9 +147,22 @@ func TestAudioEOFWaitsForExplicitSourceReplacement(t *testing.T) {
}
}
})
- offer, err := session.PrepareLocalEdge("audio-edge")
+ offer, err := session.PrepareLocalEdge("audio-edge", "local_offer_1", false)
check(err)
check(receiver.SetRemoteDescription(offer))
+ // A receive preference belongs to this answer, not the upstream offer.
+ for _, transceiver := range receiver.GetTransceivers() {
+ if transceiver.Kind() != webrtc.RTPCodecTypeAudio {
+ continue
+ }
+ for _, codec := range transceiver.Receiver().GetParameters().Codecs {
+ if codec.MimeType == webrtc.MimeTypeOpus {
+ codec.SDPFmtpLine = "stereo=1;maxaveragebitrate=510000"
+ check(transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec}))
+ break
+ }
+ }
+ }
go func() {
for {
select {
@@ -171,7 +184,7 @@ func TestAudioEOFWaitsForExplicitSourceReplacement(t *testing.T) {
case <-ctx.Done():
t.Fatal("receiver gathering timed out")
}
- check(session.SetAnswer("audio-edge", *receiver.LocalDescription()))
+ check(session.SetAnswer("audio-edge", *receiver.LocalDescription(), "local_offer_1"))
waitPackets := func(counter *atomic.Uint64, minimum uint64, label string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
diff --git a/internal/app/nativehost/profile.go b/internal/app/nativehost/profile.go
index 7e02897da..90dd27011 100644
--- a/internal/app/nativehost/profile.go
+++ b/internal/app/nativehost/profile.go
@@ -1,6 +1,9 @@
package nativehost
-import "github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
+import (
+ "github.com/TNTcraftHIM/Piik/internal/app/nativeaudio"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
+)
type QualityProfile struct {
Video nativecapture.VideoProfile
@@ -9,7 +12,5 @@ type QualityProfile struct {
func (profile QualityProfile) Valid() bool {
return profile.Video.Valid() &&
- (profile.AudioBitrate == 64_000 ||
- profile.AudioBitrate == 128_000 ||
- profile.AudioBitrate == 192_000)
+ profile.AudioBitrate >= nativeaudio.MinBitrate && profile.AudioBitrate <= nativeaudio.MaxSendBitrate
}
diff --git a/internal/app/nativehost/punch.go b/internal/app/nativehost/punch.go
new file mode 100644
index 000000000..2bb04ff15
--- /dev/null
+++ b/internal/app/nativehost/punch.go
@@ -0,0 +1,24 @@
+package nativehost
+
+import (
+ "errors"
+
+ "github.com/TNTcraftHIM/Piik/internal/app/mediaedge"
+ "github.com/pion/webrtc/v4"
+)
+
+func (session *Session) BeginPunch(connectionID string, servers []webrtc.ICEServer, secret string) (*mediaedge.PunchPreparation, error) {
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ source := session.source
+ if session.closed || source == nil || session.edges[connectionID] != nil {
+ return nil, errors.New("native share is unavailable")
+ }
+ return session.engine.BeginPunch(connectionID, source, servers, secret)
+}
+
+func (session *Session) Punch(connectionID string) *mediaedge.PunchPreparation {
+ return session.engine.Punch(connectionID)
+}
+
+func (session *Session) ClosePunch(connectionID string) { session.engine.ClosePunch(connectionID) }
diff --git a/internal/app/nativehost/session.go b/internal/app/nativehost/session.go
index 2fa37f301..60694a36c 100644
--- a/internal/app/nativehost/session.go
+++ b/internal/app/nativehost/session.go
@@ -234,20 +234,30 @@ func (session *Session) HasAudio() bool {
func (session *Session) PrepareEdge(
connectionID string,
iceServers []webrtc.ICEServer,
+ punched bool,
) (webrtc.SessionDescription, error) {
- return session.prepareEdge(connectionID, iceServers, false)
+ return session.prepareEdge(connectionID, iceServers, "", punched)
}
func (session *Session) PrepareLocalEdge(
- connectionID string,
+ connectionID, offerID string,
+ restart bool,
) (webrtc.SessionDescription, error) {
- return session.prepareEdge(connectionID, nil, true)
+ if restart {
+ edge := session.edge(connectionID)
+ if edge == nil {
+ return webrtc.SessionDescription{}, errors.New("native local media edge does not exist")
+ }
+ return edge.CreateLocalOffer(session.ctx, offerID, true)
+ }
+ return session.prepareEdge(connectionID, nil, offerID, false)
}
func (session *Session) prepareEdge(
connectionID string,
iceServers []webrtc.ICEServer,
- local bool,
+ localOfferID string,
+ punched bool,
) (webrtc.SessionDescription, error) {
session.mu.Lock()
if session.closed || session.edges[connectionID] != nil {
@@ -259,7 +269,8 @@ func (session *Session) prepareEdge(
ConnectionID: connectionID,
ICEServers: iceServers,
Audio: session.audioSource,
- Local: local,
+ Local: localOfferID != "",
+ Punched: punched,
Events: mediaedge.EdgeEvents{
LocalCandidate: func(candidate *webrtc.ICECandidateInit) {
session.emit(Event{
@@ -296,7 +307,12 @@ func (session *Session) prepareEdge(
}
session.edges[connectionID] = edge
session.mu.Unlock()
- offer, err := edge.CreateOffer()
+ var offer webrtc.SessionDescription
+ if localOfferID != "" {
+ offer, err = edge.CreateLocalOffer(session.ctx, localOfferID, false)
+ } else {
+ offer, err = edge.CreateOffer()
+ }
if err != nil {
session.CloseEdge(connectionID)
return webrtc.SessionDescription{}, err
@@ -304,12 +320,12 @@ func (session *Session) prepareEdge(
return offer, nil
}
-func (session *Session) SetAnswer(connectionID string, answer webrtc.SessionDescription) error {
+func (session *Session) SetAnswer(connectionID string, answer webrtc.SessionDescription, offerID string) error {
edge := session.edge(connectionID)
if edge == nil {
return errors.New("native media edge does not exist")
}
- return edge.SetAnswer(answer)
+ return edge.SetAnswer(answer, offerID)
}
func (session *Session) AddCandidate(
@@ -636,6 +652,7 @@ func (session *Session) disableCaptureLayer(stream *nativecapture.Stream, layer
}
func (session *Session) CloseEdge(connectionID string) {
+ session.engine.ClosePunch(connectionID)
session.mu.Lock()
edge := session.edges[connectionID]
delete(session.edges, connectionID)
@@ -709,7 +726,8 @@ func (session *Session) run() {
}()
if session.mixer != nil {
go func() { session.mixer.run(); close(audioDone) }()
- } else if session.audioStream == nil {
+ } else if session.audioSource == nil {
+ // The session's output shape is fixed; source inputs can change concurrently.
close(audioDone)
} else {
go func() {
@@ -1212,7 +1230,7 @@ func decodeCaptureState(payload []byte) (CaptureState, error) {
}
original := state.Outputs[min(1, len(state.Outputs)-1)]
if state.State == "active" &&
- ((state.Codec == "h264" && !validH264ProfileLevelID(state.ProfileLevelID)) ||
+ ((state.Codec == "h264" && !nativecapture.SupportedH264ProfileLevelID(state.ProfileLevelID)) ||
(state.Codec == "vp8" && state.ProfileLevelID != "") ||
state.Width != original.Width ||
state.Height != original.Height ||
@@ -1235,19 +1253,6 @@ func (state CaptureState) applyBackend(options *nativecapture.VideoOptions) {
}
}
-func validH264ProfileLevelID(value string) bool {
- level, valid := encoded.H264ConstrainedBaselineLevel(value)
- if !valid {
- return false
- }
- switch level {
- case 30, 31, 32, 40, 41, 42, 50, 51:
- return true
- default:
- return false
- }
-}
-
// Optional facts are emitted only to a page which opted into mixed audio.
func (session *Session) SourceAudio() *bool {
if session.mixer == nil {
diff --git a/internal/app/nativehost/session_test.go b/internal/app/nativehost/session_test.go
index 9ba885783..5c7b6b7ae 100644
--- a/internal/app/nativehost/session_test.go
+++ b/internal/app/nativehost/session_test.go
@@ -430,14 +430,14 @@ func TestCaptureStateRejectsUnknownOrUnattributedState(t *testing.T) {
}
func TestCaptureStateValidatesH264ToolsRatherThanConstraintSpelling(t *testing.T) {
- for _, profile := range []string{"42c01f", "42e01f", "42E033", "4d801f", "58c02a"} {
+ for _, profile := range []string{"42c01f", "42e01f", "42E033", "4d801f", "58c02a", "42c00a", "42c016", "42c034"} {
state, err := decodeCaptureState(captureStatePayload(t,
`{"state":"active","hardwareOnly":true,"codec":"h264","profileLevelId":"`+profile+`","width":1280,"height":720,"fps":30}`))
if err != nil || state.ProfileLevelID != profile {
t.Fatalf("equivalent Constrained Baseline %q was rejected or rewritten: %+v, %v", profile, state, err)
}
}
- for _, profile := range []string{"42001f", "4d001f", "64001f", "42e11f", "42e01", "42e01fg", "42e0gg", "42e034", "42e035", "42e03c"} {
+ for _, profile := range []string{"42001f", "4d001f", "64001f", "42e11f", "42e01", "42e01fg", "42e0gg", "42e000", "42e035", "42e03c"} {
if _, err := decodeCaptureState(captureStatePayload(t,
`{"state":"active","hardwareOnly":true,"codec":"h264","profileLevelId":"`+profile+`","width":1280,"height":720,"fps":30}`)); err == nil {
t.Fatalf("incompatible profile/level %q was admitted", profile)
diff --git a/internal/app/nativeviewer/punch.go b/internal/app/nativeviewer/punch.go
new file mode 100644
index 000000000..0777540d8
--- /dev/null
+++ b/internal/app/nativeviewer/punch.go
@@ -0,0 +1,31 @@
+package nativeviewer
+
+import (
+ "errors"
+
+ "github.com/TNTcraftHIM/Piik/internal/app/mediaedge"
+ "github.com/pion/webrtc/v4"
+)
+
+func (session *Session) BeginPunch(connectionID, sourceConnectionID string, servers []webrtc.ICEServer, secret string) (*mediaedge.PunchPreparation, error) {
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ if _, exists := session.edges[connectionID]; session.closed || exists || session.receivers[connectionID] != nil {
+ return nil, errors.New("native connection is unavailable for preparation")
+ }
+ var source *mediaedge.Source
+ if sourceConnectionID != "" {
+ receiver := session.receivers[sourceConnectionID]
+ if receiver == nil {
+ return nil, errors.New("native Viewer source is unavailable")
+ }
+ source = receiver.Source()
+ }
+ return session.engine.BeginPunch(connectionID, source, servers, secret)
+}
+
+func (session *Session) Punch(connectionID string) *mediaedge.PunchPreparation {
+ return session.engine.Punch(connectionID)
+}
+
+func (session *Session) ClosePunch(connectionID string) { session.engine.ClosePunch(connectionID) }
diff --git a/internal/app/nativeviewer/session.go b/internal/app/nativeviewer/session.go
index c48ad5d6c..53a9a803e 100644
--- a/internal/app/nativeviewer/session.go
+++ b/internal/app/nativeviewer/session.go
@@ -37,6 +37,9 @@ type sessionEdge struct {
sourceConnectionID string
}
+// One current upstream and one prepared route may overlap during a handoff.
+const receiverCapacity = 2
+
// Session owns the native media implementation for one Browser Viewer. Room,
// participant, route, and recovery authority remain in the Browser/server.
type Session struct {
@@ -117,17 +120,23 @@ func (session *Session) AcceptOffer(
offer webrtc.SessionDescription,
iceServers []webrtc.ICEServer,
reuse bool,
+ punched bool,
) (OfferResult, error) {
if connectionID == "" || len(connectionID) > 256 {
return OfferResult{}, errors.New("native Viewer receiver identity is invalid")
}
session.mu.Lock()
closed := session.closed
+ receiver := session.receivers[connectionID]
+ atCapacity := len(session.receivers) >= receiverCapacity && receiver == nil
session.mu.Unlock()
if closed {
return OfferResult{}, errors.New("native Viewer session is closed")
}
- if receiver := session.receiver(connectionID); reuse && receiver != nil {
+ if atCapacity {
+ return OfferResult{}, errors.New("native Viewer receiver capacity reached")
+ }
+ if reuse && receiver != nil {
answer, reused, err := receiver.Renegotiate(offer, iceServers)
if err != nil {
return OfferResult{}, err
@@ -136,12 +145,20 @@ func (session *Session) AcceptOffer(
return OfferResult{Answer: answer, Audio: receiver.HasAudio(), Codec: receiver.Codec(), Reused: true}, nil
}
}
+ var punchedSocket *mediaedge.PunchedSocket
+ if punched {
+ punchedSocket = session.engine.TakePunchedSocket(connectionID, nil)
+ if punchedSocket == nil {
+ return OfferResult{}, errors.New("prepared connection is unavailable")
+ }
+ }
session.CloseReceiver(connectionID)
receiver, answer, err := session.engine.NewReceiver(mediaedge.ReceiverOptions{
- Offer: offer,
- ICEServers: iceServers,
- EdgeCapacity: session.edgeCapacity,
- Relay: session.relay,
+ Offer: offer,
+ ICEServers: iceServers,
+ EdgeCapacity: session.edgeCapacity,
+ Relay: session.relay,
+ PunchedSocket: punchedSocket,
Events: mediaedge.ReceiverEvents{
LocalCandidate: func(candidate *webrtc.ICECandidateInit, current func() bool) {
session.emit(Event{
@@ -173,7 +190,7 @@ func (session *Session) AcceptOffer(
return OfferResult{}, err
}
session.mu.Lock()
- if session.closed || session.receivers[connectionID] != nil {
+ if session.closed || session.receivers[connectionID] != nil || len(session.receivers) >= receiverCapacity {
session.mu.Unlock()
_ = receiver.Close()
return OfferResult{}, errors.New("native Viewer receiver is unavailable")
@@ -203,25 +220,39 @@ func (session *Session) AddReceiverCandidate(
func (session *Session) PrepareLocalEdge(
sourceConnectionID string,
- connectionID string,
+ connectionID, offerID string,
+ restart bool,
) (webrtc.SessionDescription, error) {
- return session.prepareEdge(sourceConnectionID, connectionID, nil, true)
+ if restart {
+ session.mu.Lock()
+ current := session.edges[connectionID]
+ available := !session.closed && current.edge != nil && current.sourceConnectionID == sourceConnectionID
+ session.mu.Unlock()
+ if !available {
+ return webrtc.SessionDescription{}, errors.New("native local Viewer edge does not exist")
+ }
+ return current.edge.CreateLocalOffer(session.ctx, offerID, true)
+ }
+ return session.prepareEdge(sourceConnectionID, connectionID, nil, offerID, false)
}
func (session *Session) PrepareEdge(
sourceConnectionID string,
connectionID string,
iceServers []webrtc.ICEServer,
+ punched bool,
) (webrtc.SessionDescription, error) {
- return session.prepareEdge(sourceConnectionID, connectionID, iceServers, false)
+ return session.prepareEdge(sourceConnectionID, connectionID, iceServers, "", punched)
}
func (session *Session) prepareEdge(
sourceConnectionID string,
connectionID string,
iceServers []webrtc.ICEServer,
- local bool,
+ localOfferID string,
+ punched bool,
) (webrtc.SessionDescription, error) {
+ local := localOfferID != ""
receiver := session.receiver(sourceConnectionID)
if receiver == nil {
return webrtc.SessionDescription{}, errors.New("native Viewer source does not exist")
@@ -241,6 +272,7 @@ func (session *Session) prepareEdge(
ICEServers: iceServers,
Audio: receiver.AudioSource(),
Local: local,
+ Punched: punched,
Events: mediaedge.EdgeEvents{
LocalCandidate: func(candidate *webrtc.ICECandidateInit) {
session.emit(Event{
@@ -279,7 +311,12 @@ func (session *Session) prepareEdge(
edge: edge, sourceConnectionID: sourceConnectionID,
}
session.mu.Unlock()
- offer, err := edge.CreateOffer()
+ var offer webrtc.SessionDescription
+ if local {
+ offer, err = edge.CreateLocalOffer(session.ctx, localOfferID, false)
+ } else {
+ offer, err = edge.CreateOffer()
+ }
if err != nil {
session.CloseEdge(connectionID)
return webrtc.SessionDescription{}, err
@@ -287,12 +324,12 @@ func (session *Session) prepareEdge(
return offer, nil
}
-func (session *Session) SetAnswer(connectionID string, answer webrtc.SessionDescription) error {
+func (session *Session) SetAnswer(connectionID string, answer webrtc.SessionDescription, offerID string) error {
edge := session.edge(connectionID)
if edge == nil {
return errors.New("native Viewer edge does not exist")
}
- return edge.SetAnswer(answer)
+ return edge.SetAnswer(answer, offerID)
}
func (session *Session) AddCandidate(connectionID string, candidate *webrtc.ICECandidateInit) error {
@@ -304,6 +341,7 @@ func (session *Session) AddCandidate(connectionID string, candidate *webrtc.ICEC
}
func (session *Session) CloseReceiver(connectionID string) {
+ session.engine.ClosePunch(connectionID)
session.mu.Lock()
receiver := session.receivers[connectionID]
delete(session.receivers, connectionID)
@@ -324,6 +362,7 @@ func (session *Session) CloseReceiver(connectionID string) {
}
func (session *Session) CloseEdge(connectionID string) {
+ session.engine.ClosePunch(connectionID)
session.mu.Lock()
edge := session.edges[connectionID].edge
delete(session.edges, connectionID)
diff --git a/internal/app/nativeviewer/session_test.go b/internal/app/nativeviewer/session_test.go
index b5aafbe5b..af666bf4d 100644
--- a/internal/app/nativeviewer/session_test.go
+++ b/internal/app/nativeviewer/session_test.go
@@ -39,7 +39,7 @@ func TestOfferReuseKeepsSourceAndChildOwnership(t *testing.T) {
t.Cleanup(func() { _ = session.Close() })
for _, audio := range []bool{false, true, false} {
input := offer(audio)
- result, err := session.AcceptOffer("upstream", input, nil, reuse)
+ result, err := session.AcceptOffer("upstream", input, nil, reuse, false)
if err != nil || result.Reused || result.Audio != audio {
t.Fatalf("new media shape: %+v, %v", result, err)
}
@@ -47,11 +47,11 @@ func TestOfferReuseKeepsSourceAndChildOwnership(t *testing.T) {
t.Fatal("incompatible source retained its old edge")
}
receiver := session.receiver("upstream")
- if _, err = session.PrepareLocalEdge("upstream", "local-playback"); err != nil {
+ if _, err = session.PrepareLocalEdge("upstream", "local-playback", "local_offer_1", false); err != nil {
t.Fatal(err)
}
child := session.edge("local-playback")
- result, err = session.AcceptOffer("upstream", input, nil, reuse)
+ result, err = session.AcceptOffer("upstream", input, nil, reuse, false)
if err != nil || result.Reused != reuse {
t.Fatalf("repeat offer: %+v, %v", result, err)
}
@@ -86,3 +86,51 @@ func TestViewerProfileHasNoImplicitCaptureDefault(t *testing.T) {
t.Fatal("closed Viewer accepted a profile")
}
}
+
+func TestReceiverCapacityPreservesCurrentAndPreparedSources(t *testing.T) {
+ peer, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = peer.Close() })
+ if _, err = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil {
+ t.Fatal(err)
+ }
+ offer, err := peer.CreateOffer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := Start(t.Context(), Options{ShareID: "viewer-session", EdgeCapacity: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = session.Close() })
+ for _, id := range []string{"active", "candidate"} {
+ if _, err = session.AcceptOffer(id, offer, nil, false, false); err != nil {
+ t.Fatal(err)
+ }
+ }
+ active, candidate := session.receiver("active"), session.receiver("candidate")
+ if _, err = session.AcceptOffer("excess", offer, nil, false, false); err == nil {
+ t.Fatal("a third receiver bypassed the active/candidate bound")
+ }
+ if session.receiver("active") != active || session.receiver("candidate") != candidate || session.receiver("excess") != nil {
+ t.Fatal("capacity rejection changed an existing receiver")
+ }
+ if result, err := session.AcceptOffer("active", offer, nil, true, false); err != nil || !result.Reused {
+ t.Fatalf("existing receiver could not renegotiate at capacity: %v", err)
+ }
+ if _, err = session.PrepareLocalEdge("active", "local-preview", "preview-offer", false); err != nil {
+ t.Fatalf("capacity rejection stopped local playback preparation: %v", err)
+ }
+ if _, err = session.AcceptOffer("candidate", offer, nil, false, false); err != nil {
+ t.Fatalf("existing receiver could not be replaced at capacity: %v", err)
+ }
+ if session.receiver("active") != active || session.receiver("candidate") == candidate || session.edge("local-preview") == nil {
+ t.Fatal("receiver replacement changed the active source or its playback")
+ }
+ session.CloseReceiver("candidate")
+ if _, err = session.AcceptOffer("replacement", offer, nil, false, false); err != nil {
+ t.Fatalf("retired receiver did not release its slot: %v", err)
+ }
+}
diff --git a/internal/app/publictunnel/process.go b/internal/app/publictunnel/process.go
index d1f4f6c09..749c66ec0 100644
--- a/internal/app/publictunnel/process.go
+++ b/internal/app/publictunnel/process.go
@@ -43,8 +43,9 @@ type Process struct {
done chan struct{}
once sync.Once
- mu sync.Mutex
- err error
+ mu sync.Mutex
+ err error
+ lastDetail string
}
func PackagedExecutable() string {
@@ -166,7 +167,15 @@ func startAttempt(parent context.Context, executable, localOrigin string) (*Proc
var connectOnce sync.Once
var outputDone sync.WaitGroup
outputDone.Add(2)
- readOutput := func(reader io.Reader) {
+ recordDetail := func(text string) {
+ // One line is bounded by the scanner. Redact before UI truncation.
+ if text = diagnostics.SafeText(strings.TrimSpace(text)); text != "" {
+ process.mu.Lock()
+ process.lastDetail = text
+ process.mu.Unlock()
+ }
+ }
+ readOutput := func(reader io.Reader, stderr bool) {
defer outputDone.Done()
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), maxLogLineBytes)
@@ -178,8 +187,16 @@ func startAttempt(parent context.Context, executable, localOrigin string) (*Proc
Error string `json:"error"`
}
if json.Unmarshal([]byte(line), &entry) != nil {
+ // cloudflared's CLI also returns plain stderr errors, including
+ // Quick Tunnel allocation failures before a transport exists.
+ if stderr {
+ recordDetail(line)
+ }
slog.Debug("public-tunnel", "event", "dependency-output", "message", diagnostics.SafeText(line))
} else if entry.Level == "error" || entry.Level == "warn" || entry.Level == "fatal" {
+ if entry.Level != "warn" {
+ recordDetail(strings.Join([]string{entry.Message, entry.Error}, "\n"))
+ }
slog.Debug("public-tunnel", "event", "dependency-output", "level", entry.Level,
"message", diagnostics.SafeText(entry.Message), "cause", diagnostics.SafeText(entry.Error))
}
@@ -194,8 +211,8 @@ func startAttempt(parent context.Context, executable, localOrigin string) (*Proc
slog.Debug("public-tunnel", "event", "output-read-failed", diagnostics.Error(err))
}
}
- go readOutput(stdout)
- go readOutput(stderr)
+ go readOutput(stdout, false)
+ go readOutput(stderr, true)
go func() {
waitErr := child.Wait()
// Classify the observed exit before draining logs: later owner cleanup
@@ -209,6 +226,11 @@ func startAttempt(parent context.Context, executable, localOrigin string) (*Proc
// Wait closes the pipe readers; join them before App closes its recorder.
// Do not wait before Wait: that would bypass its bounded pipe shutdown.
outputDone.Wait()
+ process.mu.Lock()
+ if process.err != nil && process.lastDetail != "" {
+ process.err = fmt.Errorf("%w\nlast public invitation detail: %s", process.err, process.lastDetail)
+ }
+ process.mu.Unlock()
_ = os.Remove(configPath)
close(process.done)
}()
diff --git a/internal/app/publictunnel/process_test.go b/internal/app/publictunnel/process_test.go
index f5d7bb3c7..23322c9a3 100644
--- a/internal/app/publictunnel/process_test.go
+++ b/internal/app/publictunnel/process_test.go
@@ -19,6 +19,11 @@ import (
func TestMain(tests *testing.M) {
if mode := os.Getenv("PIIK_TUNNEL_FIXTURE"); mode != "" {
+ if mode == "plain-exit" {
+ fmt.Fprintln(os.Stderr, "failed to request quick Tunnel: connect timeout token=fixture-secret")
+ fmt.Fprintln(os.Stdout, "trailing ordinary output")
+ os.Exit(7)
+ }
if record := os.Getenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS"); record != "" {
previous, _ := os.ReadFile(record)
var paths []string
@@ -46,7 +51,10 @@ func TestMain(tests *testing.M) {
}
failures, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_FAILURES"))
if len(paths) < failures {
- fmt.Fprintln(os.Stderr, "fixture allocation unavailable")
+ fmt.Fprintln(os.Stderr, `{"level":"error","message":"older recovered failure"}`)
+ fmt.Fprintln(os.Stderr, `{"level":"error","message":"fixture allocation unavailable","error":"connect timeout token=fixture-secret"}`)
+ fmt.Fprintln(os.Stderr, `{"level":"warn","message":"cleanup warning"}`)
+ fmt.Fprintln(os.Stderr, `{"level":"info","message":"cleanup complete"}`)
code, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE"))
os.Exit(code)
}
@@ -83,6 +91,23 @@ func TestMain(tests *testing.M) {
os.Exit(tests.Run())
}
+func TestStartupRetainsPlainDependencyFailure(t *testing.T) {
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PIIK_TUNNEL_FIXTURE", "plain-exit")
+ process, err := startAttempt(t.Context(), executable, "http://127.0.0.1:8787")
+ var failure *exec.ExitError
+ if process != nil || !errors.Is(err, errStartupExited) || !errors.As(err, &failure) || failure.ExitCode() != 7 {
+ t.Fatalf("plain dependency failure lost its exit status: %v, %v", process, err)
+ }
+ if !strings.Contains(err.Error(), "failed to request quick Tunnel: connect timeout token=[redacted]") ||
+ strings.Contains(err.Error(), "fixture-secret") || strings.Contains(err.Error(), "ordinary output") {
+ t.Fatalf("plain dependency failure was omitted, unsafe or overwritten: %v", err)
+ }
+}
+
func TestStartupRetriesOnlyRetiredPreReadyChildren(t *testing.T) {
executable, err := os.Executable()
if err != nil {
@@ -120,6 +145,11 @@ func TestStartupRetriesOnlyRetiredPreReadyChildren(t *testing.T) {
if !errors.As(err, &failure) || failure.ExitCode() != sample.exitCode {
t.Fatalf("latest dependency exit was lost: %v", err)
}
+ if !strings.Contains(err.Error(), "fixture allocation unavailable\nconnect timeout token=[redacted]") ||
+ strings.Contains(err.Error(), "fixture-secret") || strings.Contains(err.Error(), "older recovered failure") ||
+ strings.Contains(err.Error(), "cleanup") {
+ t.Fatalf("latest sanitized dependency cause was lost or replaced: %v", err)
+ }
}
}
assertRetiredAttempts(t, record, 4)
@@ -318,6 +348,9 @@ func TestCloseKeepsAnObservedFailureWhileLogsDrain(t *testing.T) {
if !errors.As(err, &failure) || failure.ExitCode() != 7 {
t.Fatalf("late owner cancellation lost the observed dependency failure: %v", err)
}
+ if !strings.Contains(err.Error(), "fixture exit failure") {
+ t.Fatalf("late output lost the dependency detail: %v", err)
+ }
case <-time.After(2 * time.Second):
t.Fatal("tunnel did not retire after its logs drained")
}
diff --git a/internal/media/forwarding/audio.go b/internal/media/forwarding/audio.go
new file mode 100644
index 000000000..787b36f6c
--- /dev/null
+++ b/internal/media/forwarding/audio.go
@@ -0,0 +1,52 @@
+package forwarding
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+
+ "github.com/pion/sdp/v3"
+)
+
+// CheckAudioReceiveLimit admits unchanged Opus forwarding. A receiver may not
+// lower the shared source or its siblings. Piik receivers declare the full Opus
+// range; an omitted preference uses codec defaults, not an unrestricted rate.
+func CheckAudioReceiveLimit(description string, sourceCeiling int) error {
+ var session sdp.SessionDescription
+ if err := session.Unmarshal([]byte(description)); err != nil {
+ return err
+ }
+ for _, media := range session.MediaDescriptions {
+ if media.MediaName.Media != "audio" || media.MediaName.Port.Value == 0 {
+ continue
+ }
+ for _, format := range media.MediaName.Formats {
+ payload, err := strconv.ParseUint(format, 10, 8)
+ if err != nil {
+ continue
+ }
+ codec, err := session.GetCodecForPayloadType(uint8(payload))
+ if err != nil || !strings.EqualFold(codec.Name, "opus") {
+ continue
+ }
+ receiveLimit := 0
+ for _, parameter := range strings.Split(codec.Fmtp, ";") {
+ name, value, found := strings.Cut(parameter, "=")
+ if !strings.EqualFold(strings.TrimSpace(name), "maxaveragebitrate") {
+ continue
+ }
+ limit, err := strconv.Atoi(strings.TrimSpace(value))
+ if !found || err != nil || limit < 6_000 || limit > 510_000 {
+ continue // RFC 7587: ignore invalid preferences.
+ }
+ if receiveLimit == 0 || limit < receiveLimit {
+ receiveLimit = limit
+ }
+ }
+ if receiveLimit < sourceCeiling {
+ return errors.New("receiver audio bitrate limit cannot carry the shared Opus source")
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/media/forwarding/audio_test.go b/internal/media/forwarding/audio_test.go
new file mode 100644
index 000000000..7c249d0d7
--- /dev/null
+++ b/internal/media/forwarding/audio_test.go
@@ -0,0 +1,34 @@
+package forwarding
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSharedAudioRejectsOnlyAnInsufficientReceiver(t *testing.T) {
+ for _, test := range []struct {
+ parameter string
+ allowed bool
+ }{
+ {"stereo=1", false},
+ {"stereo=1;maxaveragebitrate=510000", true},
+ {"stereo=1;maxaveragebitrate=192000", false},
+ {"maxaveragebitrate=320000", false},
+ {"maxaveragebitrate=invalid", false},
+ {"maxaveragebitrate=510001", false},
+ {"maxaveragebitrate=510000;maxaveragebitrate=510001", true},
+ {"maxaveragebitrate=510000;maxaveragebitrate=96000", false},
+ } {
+ t.Run(test.parameter, func(t *testing.T) {
+ answer := "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n" +
+ "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 " + test.parameter + "\r\n"
+ if err := CheckAudioReceiveLimit(answer, 510_000); (err == nil) != test.allowed {
+ t.Fatalf("receiver admitted=%v, err=%v", test.allowed, err)
+ }
+ // Rejected or unrelated media cannot impose an audio limit.
+ if err := CheckAudioReceiveLimit(strings.Replace(answer, "m=audio 9", "m=audio 0", 1), 510_000); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
diff --git a/internal/media/forwarding/codecs.go b/internal/media/forwarding/codecs.go
new file mode 100644
index 000000000..803852c86
--- /dev/null
+++ b/internal/media/forwarding/codecs.go
@@ -0,0 +1,41 @@
+package forwarding
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/pion/interceptor"
+ "github.com/pion/rtp"
+ "github.com/pion/webrtc/v4"
+)
+
+// ReadRTP restores repaired packets using the negotiated codec. Pion v4.2.23
+// can queue RTX before a RID track's first primary read initializes PayloadType,
+// even though Codec is already known. Do not rewrite ordinary packets: their
+// payload type still has to match the source's negotiated identity.
+func ReadRTP(track *webrtc.TrackRemote) (*rtp.Packet, interceptor.Attributes, error) {
+ packet, attributes, err := track.ReadRTP()
+ if err == nil && attributes.Get(webrtc.AttributeRtxPayloadType) != nil {
+ packet.PayloadType = uint8(track.Codec().PayloadType)
+ }
+ return packet, attributes, err
+}
+
+// WithRTX restricts a negotiated offer to one primary codec and its associated
+// repair stream. Selecting a media codec must not discard packet-loss recovery.
+func WithRTX(primary webrtc.RTPCodecParameters, negotiated []webrtc.RTPCodecParameters) []webrtc.RTPCodecParameters {
+ selected := []webrtc.RTPCodecParameters{primary}
+ for _, repair := range negotiated {
+ if !strings.EqualFold(repair.MimeType, webrtc.MimeTypeRTX) {
+ continue
+ }
+ for _, field := range strings.Split(repair.SDPFmtpLine, ";") {
+ key, value, _ := strings.Cut(strings.TrimSpace(field), "=")
+ if key == "apt" && strings.TrimSpace(value) == strconv.Itoa(int(primary.PayloadType)) {
+ selected = append(selected, repair)
+ break
+ }
+ }
+ }
+ return selected
+}
diff --git a/internal/media/forwarding/ice.go b/internal/media/forwarding/ice.go
new file mode 100644
index 000000000..85c99f018
--- /dev/null
+++ b/internal/media/forwarding/ice.go
@@ -0,0 +1,29 @@
+package forwarding
+
+import (
+ "github.com/pion/ice/v4"
+ "github.com/pion/transport/v5"
+ "github.com/pion/transport/v5/stdnet"
+)
+
+// NewUDPMux retains Pion's socket and candidate policy, but reads current local
+// addresses at each gathering. Its default stdnet snapshot otherwise survives
+// ICE restart (and every new SFU connection sharing that mux).
+func NewUDPMux(params ice.UDPMuxParams) *ice.UDPMuxDefault {
+ if params.Net == nil {
+ params.Net = &muxNetwork{}
+ }
+ return ice.NewUDPMuxDefault(params)
+}
+
+// UDPMux uses Net only to enumerate interfaces. Take a fresh, private snapshot
+// per read instead of mutating a shared stdnet.Net during concurrent gatherings.
+type muxNetwork struct{ stdnet.Net }
+
+func (*muxNetwork) Interfaces() ([]*transport.Interface, error) {
+ network, err := stdnet.NewNet()
+ if err != nil {
+ return nil, err
+ }
+ return network.Interfaces()
+}
diff --git a/internal/media/forwarding/ice_linux_test.go b/internal/media/forwarding/ice_linux_test.go
new file mode 100644
index 000000000..59828e25d
--- /dev/null
+++ b/internal/media/forwarding/ice_linux_test.go
@@ -0,0 +1,120 @@
+package forwarding
+
+import (
+ "net"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pion/ice/v4"
+ "github.com/pion/webrtc/v4"
+)
+
+// Run only in a disposable network namespace with lab0 at 10.77.0.2/24.
+// This exercises real interface replacement without changing the host network.
+func TestUDPMuxRefreshesHostCandidates(t *testing.T) {
+ if os.Getenv("PIIK_INTERFACE_LAB") != "1" {
+ t.Skip("requires the isolated Linux interface fixture")
+ }
+ self, err := os.Readlink("/proc/self/ns/net")
+ if err != nil {
+ t.Fatal(err)
+ }
+ initial, err := os.Readlink("/proc/1/ns/net")
+ if err != nil || self == initial {
+ t.Fatal("refusing to change the initial network namespace")
+ }
+ iface, err := net.InterfaceByName("lab0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ addresses, err := iface.Addrs()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, address := range addresses {
+ found = found || address.String() == "10.77.0.2/24"
+ }
+ if !found {
+ t.Fatal("fixture address is absent")
+ }
+ change := func(action, address string) {
+ t.Helper()
+ if output, err := exec.Command("ip", "addr", action, address, "dev", "lab0").CombinedOutput(); err != nil {
+ t.Fatalf("fixture address change: %v: %s", err, output)
+ }
+ }
+ udp, err := net.ListenPacket("udp4", "0.0.0.0:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mux := NewUDPMux(ice.UDPMuxParams{UDPConn: udp})
+ t.Cleanup(func() { _ = mux.Close() })
+ settings := webrtc.SettingEngine{}
+ settings.SetICEUDPMux(mux)
+ settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ newPeer := func() (*webrtc.PeerConnection, *webrtc.PeerConnection) {
+ t.Helper()
+ peer, err := webrtc.NewAPI(webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = peer.Close() })
+ if _, err = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil {
+ t.Fatal(err)
+ }
+ remote, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = remote.Close() })
+ return peer, remote
+ }
+ gather := func(peer, remote *webrtc.PeerConnection, restart bool, want, reject string) {
+ t.Helper()
+ offer, err := peer.CreateOffer(&webrtc.OfferOptions{ICERestart: restart})
+ if err != nil {
+ t.Fatal(err)
+ }
+ complete := webrtc.GatheringCompletePromise(peer)
+ if err = peer.SetLocalDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-complete:
+ case <-time.After(3 * time.Second):
+ t.Fatal("candidate gathering did not finish")
+ }
+ sdp := peer.LocalDescription().SDP
+ if !strings.Contains(sdp, " "+want+" ") || (reject != "" && strings.Contains(sdp, " "+reject+" ")) {
+ t.Fatalf("gathering did not replace the old interface address: want=%s reject=%s", want, reject)
+ }
+ if err = remote.SetRemoteDescription(*peer.LocalDescription()); err != nil {
+ t.Fatal(err)
+ }
+ answer, err := remote.CreateAnswer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = remote.SetLocalDescription(answer); err != nil {
+ t.Fatal(err)
+ }
+ if err = peer.SetRemoteDescription(answer); err != nil {
+ t.Fatal(err)
+ }
+ }
+ peer, remote := newPeer()
+ gather(peer, remote, false, "10.77.0.2", "")
+ change("del", "10.77.0.2/24")
+ t.Cleanup(func() { change("add", "10.77.0.2/24") })
+ change("add", "10.77.0.3/24")
+ t.Cleanup(func() { change("del", "10.77.0.3/24") })
+ // App recovery retains its connection and socket; SFU also reuses its mux
+ // across independent connections. Both must enumerate the current address.
+ gather(peer, remote, true, "10.77.0.3", "10.77.0.2")
+ peer, remote = newPeer()
+ gather(peer, remote, false, "10.77.0.3", "10.77.0.2")
+}
diff --git a/internal/media/forwarding/output.go b/internal/media/forwarding/output.go
index 000f92b77..91cec7a7d 100644
--- a/internal/media/forwarding/output.go
+++ b/internal/media/forwarding/output.go
@@ -19,15 +19,16 @@ import (
// another scheduler. Do not attach a StreamAllocator to the same DownTrack.
type Output struct {
*sfu.DownTrack
- mu sync.Mutex
- writeMu sync.Mutex
- budget int64
- current int32
- allocation sfu.VideoAllocation
- hasAllocation bool
- closed bool
- onActivity func()
- onDemandChanged func()
+ mu sync.Mutex
+ writeMu sync.Mutex
+ budget int64
+ current int32
+ allocation sfu.VideoAllocation
+ hasAllocation bool
+ closed bool
+ feedbackObserved func() bool
+ onActivity func()
+ onDemandChanged func()
}
// Snapshot is read by the parent at its input-frame boundary. Current means
@@ -38,7 +39,7 @@ type Output struct {
type Snapshot struct {
Prepare, Target, Requested, Current int32
Paused bool
- // VideoBudget is the current connection estimate after audio reservation.
+ // VideoBudget is the committed estimate for this connection's tracked video.
// It is codec input, not a claim that this bitrate is available or delivered.
VideoBudget int64
}
@@ -71,6 +72,11 @@ func (output *Output) reconcileLocked() {
return
}
available, rates := output.Receiver().GetLayeredBitrate()
+ if len(available) > 0 {
+ // Availability can precede LiveKit's asynchronous discovery notification.
+ // Apply that observed layer before allocation can invalidate its target.
+ output.DownTrack.UpTrackMaxPublishedLayerChange(slices.Max(available))
+ }
measured := false
for _, layer := range available {
if rates[layer][0] > 0 {
@@ -78,10 +84,11 @@ func (output *Output) reconcileLocked() {
break
}
}
- if !measured {
+ if !measured || (output.feedbackObserved != nil && !output.feedbackObserved()) {
if len(available) > 0 {
- // A real layer can deliver before its bitrate window exists. The
- // library bootstrap also establishes the target for PLI recovery.
+ // Source history is not this new connection's capacity. Bootstrap
+ // through the library until video can establish real feedback;
+ // pausing against the initial estimate would prevent that feedback.
output.allocation = output.AllocateOptimal(false, false)
output.hasAllocation = true
}
diff --git a/internal/media/forwarding/output_test.go b/internal/media/forwarding/output_test.go
index c5ecf799a..825f6494a 100644
--- a/internal/media/forwarding/output_test.go
+++ b/internal/media/forwarding/output_test.go
@@ -68,12 +68,18 @@ func TestOutputUsesLibraryAllocationAndExactLifetime(t *testing.T) {
defer output.Close()
output.SetMaxSpatialLayer(2)
output.SetMaxTemporalLayer(0)
- output.UpTrackMaxPublishedLayerChange(2)
- output.UpTrackMaxTemporalLayerSeenChange(0)
if state := output.State(); state.Target != -1 || state.Current != -1 {
t.Fatalf("unmeasured source pretended to have a target: %+v", state)
}
+ // LiveKit publishes layer discovery asynchronously. Availability can arrive
+ // first; allocation must use that observation without waiting for its notice.
receiver.available = []int32{0}
+ output.UpTrackLayersChange()
+ if state := output.State(); state.Target != 0 || state.Paused {
+ t.Fatalf("available source waited for its discovery notification: %+v", state)
+ }
+ output.UpTrackMaxPublishedLayerChange(2)
+ output.UpTrackMaxTemporalLayerSeenChange(0)
output.SetBudget(0)
if state := output.State(); !state.Paused || state.Prepare != -1 || state.VideoBudget != 0 {
t.Fatalf("bootstrap ignored an exhausted video budget: %+v", state)
@@ -111,6 +117,19 @@ func TestOutputUsesLibraryAllocationAndExactLifetime(t *testing.T) {
if state := output.State(); !state.Paused || state.Target != -1 || state.Prepare != 0 || state.VideoBudget != 60_000 {
t.Fatalf("below-lowest budget cannot request codec adaptation: %+v", state)
}
+ // A new child's seed cannot veto a warm source's first video. Once feedback
+ // arrives, the same source must obey the real connection's allocation again.
+ observed := false
+ output.feedbackObserved = func() bool { return observed }
+ output.Reconcile()
+ if state := output.State(); state.Paused || state.Target < 0 {
+ t.Fatalf("warm source could not establish first feedback: %+v", state)
+ }
+ observed = true
+ output.Reconcile()
+ if state := output.State(); !state.Paused {
+ t.Fatalf("bootstrap bypassed observed raw-source limits: %+v", state)
+ }
receiver.controlled = true
output.Reconcile()
if state := output.State(); state.Paused || state.Target != 0 {
diff --git a/internal/media/forwarding/packet_buffer.go b/internal/media/forwarding/packet_buffer.go
new file mode 100644
index 000000000..ebd6d514b
--- /dev/null
+++ b/internal/media/forwarding/packet_buffer.go
@@ -0,0 +1,26 @@
+package forwarding
+
+import "github.com/pion/transport/v5/packetio"
+
+// packetBuffer adapts Pion's bounded packet queue to its SRTP BufferFactory's
+// io.ReadWriteCloser contract, retaining optional packet attributes.
+type packetBuffer struct {
+ *packetio.Buffer
+}
+
+func (buffer *packetBuffer) Read(payload []byte) (int, error) {
+ n, _, err := buffer.Buffer.Read(payload, nil)
+ return n, err
+}
+
+func (buffer *packetBuffer) Write(payload []byte) (int, error) {
+ return buffer.Buffer.Write(payload, nil)
+}
+
+func (buffer *packetBuffer) ReadWithAttributes(payload []byte, attributes packetio.Attributes) (int, packetio.Attributes, error) {
+ return buffer.Buffer.Read(payload, attributes)
+}
+
+func (buffer *packetBuffer) WriteWithAttributes(payload []byte, attributes packetio.Attributes) (int, error) {
+ return buffer.Buffer.Write(payload, attributes)
+}
diff --git a/internal/media/forwarding/packet_buffer_test.go b/internal/media/forwarding/packet_buffer_test.go
new file mode 100644
index 000000000..84096224d
--- /dev/null
+++ b/internal/media/forwarding/packet_buffer_test.go
@@ -0,0 +1,43 @@
+package forwarding
+
+import (
+ "errors"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/pion/transport/v5/packetio"
+)
+
+func TestPacketBufferRetainsSRTPContract(t *testing.T) {
+ buffer := &packetBuffer{Buffer: packetio.NewBuffer()}
+ buffer.SetLimitCount(2)
+ if err := buffer.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ var stream io.ReadWriteCloser = buffer
+ t.Cleanup(func() { _ = stream.Close() })
+ if _, err := stream.Write([]byte{1}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := buffer.WriteWithAttributes([]byte{2}, packetio.Attributes{{Key: "arrival", Value: 7}}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := stream.Write([]byte{3}); !errors.Is(err, packetio.ErrFull) {
+ t.Fatalf("queue limit lost at SRTP boundary: %v", err)
+ }
+ payload := make([]byte, 8)
+ if n, err := stream.Read(payload); err != nil || n != 1 || payload[0] != 1 {
+ t.Fatalf("ordinary SRTP read: n=%d err=%v", n, err)
+ }
+ n, attributes, err := buffer.ReadWithAttributes(payload, nil)
+ if err != nil || n != 1 || payload[0] != 2 || attributes.Get("arrival") != 7 {
+ t.Fatalf("packet or attributes lost: n=%d attributes=%v err=%v", n, attributes, err)
+ }
+ if err := stream.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := stream.Read(payload); !errors.Is(err, io.EOF) {
+ t.Fatalf("closed SRTP buffer did not finish: %v", err)
+ }
+}
diff --git a/internal/media/forwarding/publication.go b/internal/media/forwarding/publication.go
index fa1222f5e..84c1922c6 100644
--- a/internal/media/forwarding/publication.go
+++ b/internal/media/forwarding/publication.go
@@ -12,10 +12,10 @@ import (
"time"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/TNTcraftHIM/Piik/internal/thirdparty/livekit/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/livekit-server/pkg/sfu/bwe"
- "github.com/livekit/livekit-server/pkg/sfu/bwe/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu/ccutils"
"github.com/livekit/livekit-server/pkg/sfu/pacer"
"github.com/livekit/livekit-server/pkg/sfu/streamallocator"
@@ -23,7 +23,7 @@ import (
"github.com/pion/interceptor"
"github.com/pion/rtcp"
"github.com/pion/rtp"
- "github.com/pion/transport/v4/packetio"
+ "github.com/pion/transport/v5/packetio"
"github.com/pion/webrtc/v4"
)
@@ -67,16 +67,14 @@ func NewPublication(options TransportOptions) (_ *Publication, err error) {
})}
publication.bandwidth.target.Store(int64(options.InitialBitrate))
publication.pacer = &publicationPacer{headers: make(map[uint32]publicationHeaders), boundedPacer: newBoundedPacer(
- pacer.NewLeakyBucket(log, nil, 5*time.Millisecond, options.InitialBitrate), log, options.Source.maxPackets,
- ), bandwidth: publication.bandwidth}
+ pacer.NewNoQueue(log, nil), log, options.Source.maxPackets,
+ )}
publication.pacer.sender = pacer.NewBase(log, publication.bandwidth)
- publication.bandwidth.pacer = publication.pacer
publication.allocator = streamallocator.NewStreamAllocator(streamallocator.StreamAllocatorParams{
Config: streamallocator.DefaultStreamAllocatorConfig, BWE: publication.bandwidth,
Pacer: publication.pacer, Logger: log,
}, true, true)
publication.allocator.Start()
- publication.pacer.SetBitrate(options.InitialBitrate)
media := &webrtc.MediaEngine{}
codec := options.Source.Codec()
if err = media.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil {
@@ -112,7 +110,7 @@ func NewPublication(options TransportOptions) (_ *Publication, err error) {
return reader
}
}
- input := packetio.NewBuffer()
+ input := &packetBuffer{Buffer: packetio.NewBuffer()}
limit := 1_000_000
if kind == packetio.RTCPBufferPacket {
limit = 100_000
@@ -294,11 +292,8 @@ func (publication *Publication) RequiredActiveCount() int {
return publication.activeCount
}
-func (publication *Publication) SetAudioBitrate(bitrate uint32) {
- publication.bandwidth.audio.Store(int64(bitrate))
-}
func (publication *Publication) VideoBudget() int64 {
- return max(publication.bandwidth.target.Load()-publication.bandwidth.audio.Load(), 0)
+ return publication.bandwidth.target.Load()
}
func (publication *Publication) LowestLayerBudget() int64 {
remaining := publication.VideoBudget()
@@ -529,7 +524,11 @@ type publicationWriter struct {
func (writer *publicationWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) {
written, err := writer.TrackLocalWriter.WriteRTP(header, payload)
if err == nil && written > 0 {
- writer.bytes.Add(uint64(header.MarshalSize() + len(payload)))
+ size := header.MarshalSize() + len(payload)
+ if header.Padding {
+ size += int(header.PaddingSize)
+ }
+ writer.bytes.Add(uint64(size))
if writer.frames != nil && writer.primary && header.Marker && len(payload) > 0 {
writer.frames.Add(1)
}
@@ -554,18 +553,13 @@ type publicationAudioContext struct {
func (context publicationAudioContext) WriteStream() webrtc.TrackLocalWriter { return context.writer }
-// Opus uses its one existing encoder at an explicit fixed ceiling. Reserve its
-// bytes from allocator capacities. TWCC observes video; reserving unobserved
-// audio is conservative and does not claim that the estimator acknowledged it.
+// TWCC measures video and its probes. Audio shares the path but is not counted
+// in this estimate; subtracting it here would understate the video budget.
type publicationBWE struct {
*sendsidebwe.SendSideBWE
- listener bwe.BWEListener
- target atomic.Int64
- audio atomic.Int64
- observed atomic.Bool
- pacer *publicationPacer
- probeMu sync.Mutex
- completedProbe ccutils.ProbeClusterInfo
+ listener bwe.BWEListener
+ target atomic.Int64
+ observed atomic.Bool
}
func (bandwidth *publicationBWE) HandleTWCCFeedback(report *rtcp.TransportLayerCC) {
@@ -578,41 +572,23 @@ func (bandwidth *publicationBWE) SetBWEListener(listener bwe.BWEListener) {
bandwidth.SendSideBWE.SetBWEListener(bandwidth)
}
func (bandwidth *publicationBWE) OnCongestionStateChange(from, to bwe.CongestionState, capacity int64) {
- if capacity > 0 {
+ // As for an individual Transport, only a completed non-congesting probe
+ // can raise committed capacity. All consumers use that same accepted value.
+ if to == bwe.CongestionStateCongested && capacity > 0 && capacity < bandwidth.target.Load() {
bandwidth.target.Store(capacity)
- bandwidth.pacer.SetBitrate(int(capacity))
}
- bandwidth.listener.OnCongestionStateChange(from, to, max(capacity-bandwidth.audio.Load(), 0))
-}
-func (bandwidth *publicationBWE) ProbeClusterDone(info ccutils.ProbeClusterInfo) {
- bandwidth.probeMu.Lock()
- defer bandwidth.probeMu.Unlock()
- bandwidth.completedProbe = info
- bandwidth.SendSideBWE.ProbeClusterDone(info)
+ bandwidth.listener.OnCongestionStateChange(from, to, bandwidth.target.Load())
}
func (bandwidth *publicationBWE) ProbeClusterFinalize() (ccutils.ProbeSignal, int64, bool) {
- bandwidth.probeMu.Lock()
- defer bandwidth.probeMu.Unlock()
signal, capacity, done := bandwidth.SendSideBWE.ProbeClusterFinalize()
- if done {
- valid := sendsidebwe.DefaultSendSideBWEConfig.CongestionDetector.ProbeSignal.IsValid(bandwidth.completedProbe)
- bandwidth.completedProbe = ccutils.ProbeClusterInfoInvalid
- if !valid {
- // StreamAllocator may upgrade on Inconclusive too. Its capacity must
- // never promote the estimator's initial sentinel after an empty probe.
- signal = ccutils.ProbeSignalInconclusive
- capacity = min(capacity, bandwidth.target.Load())
- } else if signal == ccutils.ProbeSignalNotCongesting && capacity > bandwidth.target.Load() {
- bandwidth.target.Store(capacity)
- bandwidth.pacer.SetBitrate(int(capacity))
- }
+ if done && signal == ccutils.ProbeSignalNotCongesting && capacity > bandwidth.target.Load() {
+ bandwidth.target.Store(capacity)
}
- return signal, max(capacity-bandwidth.audio.Load(), 0), done
+ return signal, bandwidth.target.Load(), done
}
type publicationPacer struct {
*boundedPacer
- bandwidth *publicationBWE
headersMu sync.Mutex
headers map[uint32]publicationHeaders
}
@@ -634,13 +610,8 @@ func (queue *publicationPacer) Enqueue(packet *pacer.Packet) {
}
queue.boundedPacer.Enqueue(packet)
}
-func (queue *publicationPacer) StartProbeCluster(info ccutils.ProbeClusterInfo) {
- queue.boundedPacer.StartProbeCluster(info)
- queue.SetBitrate(info.Goal.DesiredBps + int(queue.bandwidth.audio.Load()))
-}
func (queue *publicationPacer) EndProbeCluster(id ccutils.ProbeClusterId) ccutils.ProbeClusterInfo {
info := queue.boundedPacer.EndProbeCluster(id)
queue.cancelProbes(id)
- queue.SetBitrate(int(queue.bandwidth.target.Load()))
return info
}
diff --git a/internal/media/forwarding/publication_test.go b/internal/media/forwarding/publication_test.go
index 79ce6287d..9dc2d8570 100644
--- a/internal/media/forwarding/publication_test.go
+++ b/internal/media/forwarding/publication_test.go
@@ -9,13 +9,15 @@ import (
"time"
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
+ "github.com/TNTcraftHIM/Piik/internal/thirdparty/livekit/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/livekit/livekit-server/pkg/sfu/bwe"
- "github.com/livekit/livekit-server/pkg/sfu/bwe/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu/ccutils"
"github.com/livekit/livekit-server/pkg/sfu/pacer"
"github.com/livekit/protocol/logger"
"github.com/pion/interceptor"
+ "github.com/pion/interceptor/pkg/twcc"
+ "github.com/pion/rtcp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
@@ -23,26 +25,24 @@ import (
func TestPublicationEmptyProbeCannotIncreaseCapacity(t *testing.T) {
const target = int64(1_000_000)
- const audio = int64(64_000)
log := logger.GetDiscardLogger()
bandwidth := &publicationBWE{SendSideBWE: sendsidebwe.NewSendSideBWE(sendsidebwe.SendSideBWEParams{
Config: sendsidebwe.DefaultSendSideBWEConfig, Logger: log,
})}
bandwidth.target.Store(target)
- bandwidth.audio.Store(audio)
- bandwidth.pacer = &publicationPacer{bandwidth: bandwidth, boundedPacer: newBoundedPacer(
- pacer.NewLeakyBucket(log, nil, 5*time.Millisecond, int(target)), log, 500,
+ queue := &publicationPacer{boundedPacer: newBoundedPacer(
+ pacer.NewNoQueue(log, nil), log, 500,
)}
- defer bandwidth.pacer.Stop()
+ defer queue.Stop()
info := ccutils.ProbeClusterInfo{Id: 1, CreatedAt: time.Now(), Goal: ccutils.ProbeClusterGoal{
- AvailableBandwidthBps: int(target), ExpectedUsageBps: int(target - audio),
+ AvailableBandwidthBps: int(target), ExpectedUsageBps: int(target),
DesiredBps: int(target * 2), Duration: bandwidth.ProbeDuration(),
}}
info.Goal.DesiredBytes = int(float64(info.Goal.DesiredBps) * info.Goal.Duration.Seconds() / 8)
bandwidth.ProbeClusterStarting(info)
- bandwidth.pacer.StartProbeCluster(info)
+ queue.StartProbeCluster(info)
time.Sleep(info.Goal.Duration)
- info = bandwidth.pacer.EndProbeCluster(info.Id)
+ info = queue.EndProbeCluster(info.Id)
if info.Result.Bytes() != 0 || sendsidebwe.DefaultSendSideBWEConfig.CongestionDetector.ProbeSignal.IsValid(info) {
t.Fatal("fixture did not complete an empty invalid probe")
}
@@ -51,12 +51,9 @@ func TestPublicationEmptyProbeCannotIncreaseCapacity(t *testing.T) {
for {
signal, capacity, done := bandwidth.ProbeClusterFinalize()
if done {
- if signal != ccutils.ProbeSignalInconclusive || capacity > target-audio || bandwidth.target.Load() != target {
+ if signal != ccutils.ProbeSignalInconclusive || capacity > target || bandwidth.target.Load() != target {
t.Fatalf("empty probe raised publication capacity: signal=%v capacity=%d target=%d", signal, capacity, bandwidth.target.Load())
}
- if bandwidth.completedProbe.Id != ccutils.ProbeClusterIdInvalid {
- t.Fatal("completed probe was retained after finalization")
- }
break
}
if time.Now().After(deadline) {
@@ -66,6 +63,99 @@ func TestPublicationEmptyProbeCannotIncreaseCapacity(t *testing.T) {
}
}
+func TestPublicationProbeUsesAcknowledgedCapacity(t *testing.T) {
+ for _, received := range []bool{true, false} {
+ t.Run(map[bool]string{true: "received", false: "no-feedback"}[received], func(t *testing.T) {
+ checkPublicationProbeCapacity(t, received)
+ })
+ }
+}
+
+func checkPublicationProbeCapacity(t *testing.T, received bool) {
+ t.Helper()
+ const target = int64(100_000)
+ bandwidth := &publicationBWE{SendSideBWE: sendsidebwe.NewSendSideBWE(sendsidebwe.SendSideBWEParams{
+ Config: sendsidebwe.DefaultSendSideBWEConfig, Logger: logger.GetDiscardLogger(),
+ })}
+ bandwidth.target.Store(target)
+ bandwidth.SetBWEListener(&publicationCapacityRecorder{})
+ // A real non-congesting probe after reset still carries the library's
+ // initial capacity. A successful 800 kbps probe cannot prove 100 Mbps.
+ start := time.Now().Add(-time.Second)
+ recorder := twcc.NewRecorder(42)
+ // Establish transport history before the idle interval. The initial packet
+ // has relative time zero, so include a subsequent packet as in a live stream.
+ for _, age := range []time.Duration{6 * time.Second, 5 * time.Second} {
+ previous := start.Add(-age).UnixMicro()
+ sequence := bandwidth.RecordPacketSendAndGetSequenceNumber(previous, 200, false, ccutils.ProbeClusterIdInvalid, false)
+ recorder.Record(42, sequence, previous+10_000)
+ }
+ for _, packet := range recorder.BuildFeedbackPacket() {
+ bandwidth.HandleTWCCFeedback(packet.(*rtcp.TransportLayerCC))
+ }
+ bandwidth.Reset()
+ info := ccutils.ProbeClusterInfo{Id: 1, CreatedAt: start, Goal: ccutils.ProbeClusterGoal{
+ AvailableBandwidthBps: int(target), DesiredBps: 800_000,
+ Duration: 200 * time.Millisecond, DesiredBytes: 20_000,
+ }}
+ bandwidth.ProbeClusterStarting(info)
+ for index := range 100 {
+ at := start.Add(time.Duration(index) * 2 * time.Millisecond).UnixMicro()
+ sequence := bandwidth.RecordPacketSendAndGetSequenceNumber(at, 200, false, info.Id, true)
+ recorder.Record(42, sequence, at+10_000)
+ }
+ info.Result = ccutils.ProbeClusterResult{StartTime: start.UnixNano(),
+ EndTime: start.Add(info.Goal.Duration).UnixNano(), PacketsProbe: 100, BytesProbe: 20_000, IsCompleted: true}
+ bandwidth.ProbeClusterDone(info)
+ if received {
+ for _, packet := range recorder.BuildFeedbackPacket() {
+ bandwidth.HandleTWCCFeedback(packet.(*rtcp.TransportLayerCC))
+ }
+ }
+ deadline := time.Now().Add(sendsidebwe.DefaultSendSideBWEConfig.CongestionDetector.ProbePacketGroup.SettleWaitMax + time.Second)
+ for {
+ signal, capacity, done := bandwidth.ProbeClusterFinalize()
+ if done {
+ if received {
+ // Allow the first-packet/time-window difference in the library's
+ // acknowledged rate, but never its untouched 100 Mbps initial value.
+ if signal != ccutils.ProbeSignalNotCongesting || capacity <= target || capacity > 1_000_000 {
+ t.Fatalf("unexpected acknowledged probe capacity: signal=%v committed=%d sent=%.0f", signal, capacity, info.Result.Bitrate())
+ }
+ } else if signal != ccutils.ProbeSignalInconclusive || capacity != target {
+ t.Fatalf("unacknowledged probe changed capacity: signal=%v committed=%d", signal, capacity)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("probe did not finalize within the library settle bound")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+type publicationCapacityRecorder struct{ capacity int64 }
+
+func (recorder *publicationCapacityRecorder) OnCongestionStateChange(_, _ bwe.CongestionState, capacity int64) {
+ recorder.capacity = capacity
+}
+
+func TestPublicationCongestionUsesCommittedCapacity(t *testing.T) {
+ recorder := &publicationCapacityRecorder{}
+ bandwidth := &publicationBWE{listener: recorder}
+ bandwidth.target.Store(1_200_000)
+ bandwidth.OnCongestionStateChange(bwe.CongestionStateNone, bwe.CongestionStateEarlyWarning, 100_000_000)
+ bandwidth.OnCongestionStateChange(bwe.CongestionStateEarlyWarning, bwe.CongestionStateCongested, 100_000_000)
+ if bandwidth.target.Load() != 1_200_000 || recorder.capacity != 1_200_000 {
+ t.Fatalf("publication promoted an unverified capacity: target=%d allocation=%d", bandwidth.target.Load(), recorder.capacity)
+ }
+ bandwidth.OnCongestionStateChange(bwe.CongestionStateCongested, bwe.CongestionStateCongested, 500_000)
+ bandwidth.OnCongestionStateChange(bwe.CongestionStateCongested, bwe.CongestionStateNone, 800_000)
+ if bandwidth.target.Load() != 500_000 || recorder.capacity != 500_000 {
+ t.Fatalf("publication disagrees with its committed video budget: target=%d allocation=%d", bandwidth.target.Load(), recorder.capacity)
+ }
+}
+
func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second)
defer cancel()
@@ -92,9 +182,8 @@ func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
publication, err := NewPublication(TransportOptions{Source: source.Source, Settings: settings, ConnectionID: "publication", InitialBitrate: 1_000_000, Audio: audio})
check(err)
defer publication.Close()
- publication.SetAudioBitrate(64_000)
- if publication.VideoBudget() != 936_000 {
- t.Fatal("audio did not reserve the shared budget")
+ if publication.VideoBudget() != 1_000_000 {
+ t.Fatal("audio changed the video capacity estimate")
}
if len(publication.PC.GetSenders()) != 2 || len(publication.PC.GetSenders()[0].GetParameters().Encodings) != 2 {
t.Fatal("publication must use one simulcast video sender and one audio sender")
@@ -105,7 +194,6 @@ func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
check(mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: "urn:ietf:params:rtp-hdrext:sdes:mid"}, webrtc.RTPCodecTypeVideo))
registry := &interceptor.Registry{}
check(webrtc.RegisterDefaultInterceptors(mediaEngine, registry))
- check(webrtc.ConfigureTWCCSender(mediaEngine, registry))
receiver, err := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(registry), webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
check(err)
defer receiver.Close()
@@ -199,7 +287,7 @@ func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
t.Fatalf("successful RTP writes were not accounted per encoding: %+v", counters)
}
// Once real bitrate measurements exist, one shared capacity update must
- // constrain the sum of both RID allocations after reserving Opus.
+ // constrain the sum of both video RID allocations.
for {
_, rates := source.GetLayeredBitrate()
if rates[0][0] > 0 && rates[1][0] > 0 {
@@ -212,12 +300,12 @@ func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
t.Fatal("publication did not measure both available encodings")
}
}
- publication.bandwidth.OnCongestionStateChange(bwe.CongestionStateNone, bwe.CongestionStateCongested, 65_000)
+ publication.bandwidth.OnCongestionStateChange(bwe.CongestionStateNone, bwe.CongestionStateCongested, 1_000)
for publication.tracks[0].BandwidthRequested()+publication.tracks[1].BandwidthRequested() > 1_000 {
select {
case <-time.After(5 * time.Millisecond):
case <-ctx.Done():
- t.Fatal("simulcast allocations exceeded their shared post-audio capacity")
+ t.Fatal("simulcast allocations exceeded their shared video capacity")
}
}
if publication.LowestLayerBudget() != 1_000 {
@@ -226,7 +314,10 @@ func TestPublicationSimulcastReuseAudioDemandAndClose(t *testing.T) {
if publication.bandwidth.CongestionState() != bwe.CongestionStateNone || !publication.Counters().Limited {
t.Fatalf("clear transport feedback hid the committed publication allocation deficit: state=%v counters=%+v low=%v high=%v", publication.bandwidth.CongestionState(), publication.Counters(), publication.tracks[0].IsDeficient(), publication.tracks[1].IsDeficient())
}
- publication.bandwidth.OnCongestionStateChange(bwe.CongestionStateCongested, bwe.CongestionStateCongested, 1_000_000)
+ // Restore this allocation fixture's capacity explicitly. A congestion event
+ // cannot prove an increase; this test covers RID reuse, not bandwidth probing.
+ publication.bandwidth.target.Store(1_000_000)
+ publication.allocator.SetChannelCapacity(1_000_000)
check(publication.SetActiveCount(1))
if publication.RequiredActiveCount() != 1 {
t.Fatal("publication did not reduce its direct demand")
diff --git a/internal/media/forwarding/recovery_test.go b/internal/media/forwarding/recovery_test.go
index 7102f4e90..733ad6ede 100644
--- a/internal/media/forwarding/recovery_test.go
+++ b/internal/media/forwarding/recovery_test.go
@@ -2,27 +2,41 @@ package forwarding
import (
"context"
+ "encoding/binary"
+ "math"
+ "math/rand/v2"
"os"
+ "sync"
"sync/atomic"
"testing"
"time"
+ "github.com/TNTcraftHIM/Piik/internal/app/nativeaudio"
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/logging"
- "github.com/pion/transport/v4/vnet"
+ "github.com/pion/transport/v5/vnet"
"github.com/pion/webrtc/v4"
+ rtmedia "github.com/pion/webrtc/v4/pkg/media"
+ "github.com/thesyncim/gopus"
)
// Explicit CPU-only network gate: synthetic VP8-shaped payload, actual Pion
-// ICE/DTLS/RTP/TWCC, and the production allocator/pacer/probe driver. No decoder,
+// ICE/DTLS/RTP/TWCC, and the production allocator/pacer/probe driver. The audio
+// case adds real Opus encode/decode and live bitrate changes. No video decoder,
// capture device or physical network participates.
func TestTransportConstrainedDormantRecovery(t *testing.T) {
if os.Getenv("PIIK_TRANSPORT_RECOVERY") == "" {
t.Skip("set PIIK_TRANSPORT_RECOVERY=1 for the bounded virtual-network gate")
}
+ t.Run("video-only", func(t *testing.T) { checkTransportRecovery(t, false) })
+ t.Run("numeric-audio", func(t *testing.T) { checkTransportRecovery(t, true) })
+}
+
+func checkTransportRecovery(t *testing.T, withAudio bool) {
+ t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 32*time.Second)
defer cancel()
check := func(err error) {
@@ -58,8 +72,36 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
sendSettings.SetNet(sendNet)
sendSettings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
sendSettings.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
+ var audio *webrtc.TrackLocalStaticSample
+ var encoder *nativeaudio.Encoder
+ var audioTicks <-chan time.Time
+ var pcm []byte
+ if withAudio {
+ audio, err = webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{
+ MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2,
+ SDPFmtpLine: "minptime=10;useinbandfec=1",
+ }, "audio", "recovery")
+ check(err)
+ encoder, err = nativeaudio.NewEncoder(320_000)
+ check(err)
+ ticker := time.NewTicker(20 * time.Millisecond)
+ defer ticker.Stop()
+ audioTicks = ticker.C
+ pcm = make([]byte, nativeaudio.FrameBytes)
+ random := rand.New(rand.NewPCG(42, 1))
+ for frame := range nativeaudio.FrameSamples {
+ for channel := range nativeaudio.Channels {
+ value := 8000*math.Sin(2*math.Pi*float64(500+channel*1000)*float64(frame)/48000) + 4000*(random.Float64()*2-1)
+ binary.LittleEndian.PutUint16(pcm[(frame*2+channel)*2:], uint16(int16(value)))
+ }
+ }
+ }
+ var audioTrack webrtc.TrackLocal
+ if audio != nil {
+ audioTrack = audio
+ }
transport, err := NewTransport(TransportOptions{Source: source.Source, Settings: sendSettings,
- ConnectionID: "recovery-child", InitialBitrate: 1_200_000})
+ ConnectionID: "recovery-child", InitialBitrate: 1_200_000, Audio: audioTrack})
check(err)
defer transport.Close()
receiveSettings := webrtc.SettingEngine{}
@@ -70,13 +112,42 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
check(media.RegisterDefaultCodecs())
registry := &interceptor.Registry{}
check(webrtc.RegisterDefaultInterceptors(media, registry))
- check(webrtc.ConfigureTWCCSender(media, registry))
receiver, err := webrtc.NewAPI(webrtc.WithMediaEngine(media), webrtc.WithInterceptorRegistry(registry),
webrtc.WithSettingEngine(receiveSettings)).NewPeerConnection(webrtc.Configuration{})
check(err)
+ var readers sync.WaitGroup
+ defer readers.Wait()
defer receiver.Close()
- var received atomic.Uint64
+ var received, audioDecoded, audioErrors atomic.Uint64
receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
+ readers.Add(1)
+ defer readers.Done()
+ if track.Kind() == webrtc.RTPCodecTypeAudio {
+ decoder, err := gopus.NewDecoder(gopus.DefaultDecoderConfig(48000, 2))
+ if err != nil {
+ audioErrors.Add(1)
+ return
+ }
+ pcm := make([]int16, nativeaudio.FrameSamples*nativeaudio.Channels)
+ for {
+ packet, _, err := track.ReadRTP()
+ if err != nil {
+ return
+ }
+ count, err := decoder.DecodeInt16(packet.Payload, pcm)
+ if err != nil || count != nativeaudio.FrameSamples {
+ audioErrors.Add(1)
+ continue
+ }
+ var energy int64
+ for _, sample := range pcm {
+ energy += int64(sample) * int64(sample)
+ }
+ if energy > int64(len(pcm))*100 {
+ audioDecoded.Add(1)
+ }
+ }
+ }
for {
packet, _, readErr := track.ReadRTP()
if readErr != nil {
@@ -117,12 +188,26 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
ticker := time.NewTicker(period)
defer ticker.Stop()
start, lastReport := time.Now(), time.Now()
- var previousReceived uint64
- var dormant, recovered bool
+ var previousReceived, previousAudio uint64
+ var dormant bool
+ var recoveredWindows, audioWindows int
minimum := int64(1_200_000)
- for frame := 0; ; frame++ {
+ frame := 0
+ for {
select {
case <-ticker.C:
+ case <-audioTicks:
+ bitrate := 320_000
+ if elapsed := time.Since(start); elapsed >= 8*time.Second && elapsed < 16*time.Second {
+ bitrate = 96_000
+ }
+ if encoder.Bitrate() != bitrate {
+ check(encoder.SetBitrate(bitrate))
+ }
+ packet, err := encoder.Encode(pcm)
+ check(err)
+ check(audio.WriteSample(rtmedia.Sample{Data: packet, Duration: 20 * time.Millisecond}))
+ continue
case <-ctx.Done():
t.Fatal(ctx.Err())
}
@@ -137,6 +222,9 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
}
link.SetRate(capacity)
state := transport.Output.State()
+ if state.VideoBudget > 6_000_000 {
+ t.Fatalf("probe promoted capacity beyond this fixture's link: %+v", state)
+ }
_, observed := transport.TargetBitrate()
high := !observed || state.Target == 1 || state.Prepare == 1 || !state.Paused && state.Current == 1
if elapsed >= 7*time.Second && elapsed < 12*time.Second {
@@ -154,6 +242,7 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
copy(data, sfu.VP8KeyFrame8x8)
check(source.WriteFrame(layer, encoded.Frame{Data: data, PTS: pts, Duration: period}))
}
+ frame++
if now.Sub(lastReport) >= time.Second {
bytes := received.Load()
receivedBps := float64((bytes-previousReceived)*8) / now.Sub(lastReport).Seconds()
@@ -162,15 +251,23 @@ func TestTransportConstrainedDormantRecovery(t *testing.T) {
elapsed.Seconds(), capacity, state.VideoBudget, state.Target, state.Current, high, receivedBps,
transport.estimator.CongestionState(), transport.paddingReady, transport.probe.Id, transport.Egress())
transport.mu.Unlock()
- if elapsed > 15*time.Second && state.Current == 1 && receivedBps > 800_000 {
- recovered = true
+ if elapsed >= 20*time.Second && state.Current == 1 && receivedBps > 800_000 {
+ recoveredWindows++
+ }
+ decoded := audioDecoded.Load()
+ if withAudio {
+ t.Logf("audio decoded=%d errors=%d bitrate=%d", decoded-previousAudio, audioErrors.Load(), encoder.Bitrate())
+ if elapsed >= 20*time.Second && decoded-previousAudio >= 35 {
+ audioWindows++
+ }
}
+ previousAudio = decoded
lastReport, previousReceived = now, bytes
}
}
- if !dormant || minimum >= 1_200_000 || !recovered {
- t.Fatalf("missing real constrained/dormant/released recovery: dormant=%v minimum=%d recovered=%v state=%+v",
- dormant, minimum, recovered, transport.Output.State())
+ if !dormant || minimum >= 1_200_000 || recoveredWindows < 5 || withAudio && (audioWindows < 5 || audioErrors.Load() != 0) {
+ t.Fatalf("missing constrained/dormant/released recovery: dormant=%v minimum=%d videoWindows=%d audioWindows=%d audioErrors=%d state=%+v",
+ dormant, minimum, recoveredWindows, audioWindows, audioErrors.Load(), transport.Output.State())
}
check(transport.Close())
if transport.prober.IsRunning() {
diff --git a/internal/media/forwarding/rtp_test.go b/internal/media/forwarding/rtp_test.go
new file mode 100644
index 000000000..932342b87
--- /dev/null
+++ b/internal/media/forwarding/rtp_test.go
@@ -0,0 +1,172 @@
+package forwarding
+
+import (
+ "bytes"
+ "context"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pion/interceptor"
+ "github.com/pion/rtcp"
+ "github.com/pion/rtp"
+ "github.com/pion/webrtc/v4"
+)
+
+// The second repair read begins only after Pion has queued the first repair.
+// This fences the startup race without sleeping or accessing library internals.
+type queuedRepair struct {
+ interceptor.NoOp
+ queued chan struct{}
+ once sync.Once
+}
+
+func (observer *queuedRepair) NewInterceptor(string) (interceptor.Interceptor, error) {
+ return observer, nil
+}
+
+// Raw Pion tracks leave simulcast extensions to their sender. Mirror a Browser's
+// repaired RID so the receiver binds the retransmission to the original layer.
+func (*queuedRepair) BindLocalStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
+ var rid, repaired uint8
+ for _, extension := range info.RTPHeaderExtensions {
+ if extension.URI == publicationRIDExtension {
+ rid = uint8(extension.ID)
+ } else if extension.URI == "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id" {
+ repaired = uint8(extension.ID)
+ }
+ }
+ return interceptor.RTPWriterFunc(func(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
+ if header.SSRC == info.SSRCRetransmission && rid != 0 && repaired != 0 {
+ repairedHeader := header.Clone()
+ value := repairedHeader.GetExtension(rid)
+ if err := repairedHeader.DelExtension(rid); err != nil {
+ return 0, err
+ }
+ if err := repairedHeader.SetExtension(repaired, value); err != nil {
+ return 0, err
+ }
+ header = &repairedHeader
+ }
+ return writer.Write(header, payload, attributes)
+ })
+}
+
+func (observer *queuedRepair) BindRemoteStream(info *interceptor.StreamInfo, reader interceptor.RTPReader) interceptor.RTPReader {
+ if !strings.EqualFold(info.MimeType, webrtc.MimeTypeRTX) {
+ return reader
+ }
+ read := false
+ return interceptor.RTPReaderFunc(func(data []byte, attributes interceptor.Attributes) (int, interceptor.Attributes, error) {
+ if read {
+ observer.once.Do(func() { close(observer.queued) })
+ }
+ read = true
+ return reader.Read(data, attributes)
+ })
+}
+
+func TestReadRTPEarlySimulcastRepairRetainsNegotiatedIdentity(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+ defer cancel()
+ check := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ observer := &queuedRepair{queued: make(chan struct{})}
+ newPeer := func() *webrtc.PeerConnection {
+ engine := &webrtc.MediaEngine{}
+ check(engine.RegisterDefaultCodecs())
+ registry := &interceptor.Registry{}
+ registry.Add(observer)
+ check(webrtc.RegisterDefaultInterceptors(engine, registry))
+ settings := webrtc.SettingEngine{}
+ settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ settings.SetIncludeLoopbackCandidate(true)
+ settings.SetIPFilter(func(ip net.IP) bool { return ip.IsLoopback() })
+ peer, err := webrtc.NewAPI(webrtc.WithMediaEngine(engine), webrtc.WithInterceptorRegistry(registry),
+ webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
+ check(err)
+ t.Cleanup(func() { _ = peer.Close() })
+ return peer
+ }
+ publisher, subscriber := newPeer(), newPeer()
+ codec := webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000}
+ low, err := webrtc.NewTrackLocalStaticRTP(codec, "screen", "repair", webrtc.WithRTPStreamID("q"))
+ check(err)
+ high, err := webrtc.NewTrackLocalStaticRTP(codec, "screen", "repair", webrtc.WithRTPStreamID("h"))
+ check(err)
+ sender, err := publisher.AddTrack(low)
+ check(err)
+ check(sender.AddEncoding(high))
+ go func() {
+ for {
+ if _, _, err := sender.ReadRTCP(); err != nil {
+ return
+ }
+ }
+ }()
+ tracks := make(chan *webrtc.TrackRemote, 1)
+ subscriber.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { tracks <- track })
+ negotiate := func(from, to *webrtc.PeerConnection, description webrtc.SessionDescription) {
+ gathered := webrtc.GatheringCompletePromise(from)
+ check(from.SetLocalDescription(description))
+ select {
+ case <-gathered:
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ check(to.SetRemoteDescription(*from.LocalDescription()))
+ }
+ offer, err := publisher.CreateOffer(nil)
+ check(err)
+ negotiate(publisher, subscriber, offer)
+ answer, err := subscriber.CreateAnswer(nil)
+ check(err)
+ negotiate(subscriber, publisher, answer)
+ packet := &rtp.Packet{Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: 1, Timestamp: 3000}, Payload: []byte{0x10, 0x00}}
+ for _, extension := range sender.GetParameters().HeaderExtensions {
+ switch extension.URI {
+ case "urn:ietf:params:rtp-hdrext:sdes:mid":
+ check(packet.SetExtension(uint8(extension.ID), []byte(publisher.GetTransceivers()[0].Mid())))
+ case publicationRIDExtension:
+ check(packet.SetExtension(uint8(extension.ID), []byte("q")))
+ }
+ }
+ ticker := time.NewTicker(10 * time.Millisecond)
+ defer ticker.Stop()
+ var remote *webrtc.TrackRemote
+ for remote == nil {
+ select {
+ case remote = <-tracks:
+ case <-ticker.C:
+ check(low.WriteRTP(packet))
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ }
+ check(subscriber.WriteRTCP([]rtcp.Packet{&rtcp.TransportLayerNack{
+ MediaSSRC: uint32(remote.SSRC()), Nacks: []rtcp.NackPair{{PacketID: 1}},
+ }}))
+ select {
+ case <-observer.queued:
+ case <-ctx.Done():
+ t.Fatal("early repair was not queued:", ctx.Err())
+ }
+ check(remote.SetReadDeadline(time.Now().Add(time.Second)))
+ for {
+ received, attributes, err := ReadRTP(remote)
+ check(err)
+ if received.PayloadType != uint8(remote.Codec().PayloadType) || received.SSRC != uint32(remote.SSRC()) ||
+ received.SequenceNumber != packet.SequenceNumber || !bytes.Equal(received.Payload, packet.Payload) {
+ t.Fatalf("repaired packet lost its negotiated identity: payload type=%d, codec=%d", received.PayloadType, remote.Codec().PayloadType)
+ }
+ if attributes.Get(webrtc.AttributeRtxPayloadType) != nil {
+ break
+ }
+ }
+}
diff --git a/internal/media/forwarding/source.go b/internal/media/forwarding/source.go
index c4037299a..a1b553be5 100644
--- a/internal/media/forwarding/source.go
+++ b/internal/media/forwarding/source.go
@@ -96,6 +96,19 @@ func (source *Source) TrackInfo() *livekit.TrackInfo {
return utils.CloneProto(source.currentTrackInfo.Load())
}
+// ReceiverBase reads initial metadata before registering the subscriber. A
+// concurrent discovery notification can fall into that gap. Refresh after
+// registration; DownTrack's seen-layer setters retain the highest observation.
+func (source *Source) AddDownTrack(track sfu.TrackSender) error {
+ if err := source.ReceiverBase.AddDownTrack(track); err != nil {
+ return err
+ }
+ tracker := source.StreamTrackerManager()
+ track.UpTrackMaxPublishedLayerChange(tracker.GetMaxPublishedLayer())
+ track.UpTrackMaxTemporalLayerSeenChange(tracker.GetMaxTemporalLayerSeen())
+ return nil
+}
+
func canonicalHeaderExtensions(extensions []webrtc.RTPHeaderExtensionParameter) []webrtc.RTPHeaderExtensionParameter {
result := slices.Clone(extensions)
slices.SortFunc(result, func(left, right webrtc.RTPHeaderExtensionParameter) int {
diff --git a/internal/media/forwarding/source_test.go b/internal/media/forwarding/source_test.go
index e7a24002f..a3123ddcd 100644
--- a/internal/media/forwarding/source_test.go
+++ b/internal/media/forwarding/source_test.go
@@ -2,6 +2,7 @@ package forwarding
import (
"errors"
+ "sync/atomic"
"testing"
"time"
@@ -14,6 +15,67 @@ import (
"github.com/pion/webrtc/v4"
)
+type subscribingSourceTrack struct {
+ *Output
+ publish func()
+ spatial atomic.Int32
+}
+
+func (track *subscribingSourceTrack) UpTrackMaxPublishedLayerChange(layer int32) {
+ if layer < 0 {
+ track.publish()
+ }
+ track.Output.UpTrackMaxPublishedLayerChange(layer)
+ track.spatial.Store(layer)
+}
+
+type observedSourceLayers struct {
+ sfu.StreamTrackerManagerListener
+ published chan struct{}
+}
+
+func (observer *observedSourceLayers) OnMaxPublishedLayerChanged(layer int32) {
+ observer.StreamTrackerManagerListener.OnMaxPublishedLayerChanged(layer)
+ observer.published <- struct{}{}
+}
+
+func TestSourceSubscriptionRetainsConcurrentLayerDiscovery(t *testing.T) {
+ source, err := NewSource(SourceOptions{ID: "screen", StreamID: "subscription",
+ Codec: webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{
+ MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000,
+ }, PayloadType: 96},
+ Formats: []LayerFormat{{Width: 8, Height: 8, Bitrate: 300_000}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(source.Close)
+ transport, err := NewTransport(TransportOptions{Source: source, ConnectionID: "joining", InitialBitrate: 1_000_000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = transport.Close() })
+ observed := &observedSourceLayers{StreamTrackerManagerListener: source.ReceiverBase, published: make(chan struct{}, 1)}
+ source.StreamTrackerManager().SetListener(observed)
+ track := &subscribingSourceTrack{Output: transport.Output}
+ track.publish = func() {
+ // Discover a layer after reading the initial snapshot but before the
+ // subscriber is registered. Complete its real notification in that gap.
+ source.StreamTrackerManager().AddTracker(0)
+ select {
+ case <-observed.published:
+ case <-time.After(time.Second):
+ t.Fatal("source did not publish its discovered layer")
+ }
+ }
+ if err = source.AddDownTrack(track); err != nil {
+ t.Fatal(err)
+ }
+ if track.spatial.Load() != source.StreamTrackerManager().GetMaxPublishedLayer() {
+ t.Fatalf("subscriber retained the pre-discovery snapshot: %d", track.spatial.Load())
+ }
+}
+
func TestSourceLayerBindingFeedbackAndClose(t *testing.T) {
codec := webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000,
diff --git a/internal/media/forwarding/temporal_test.go b/internal/media/forwarding/temporal_test.go
index 3fba1ad85..28ae13564 100644
--- a/internal/media/forwarding/temporal_test.go
+++ b/internal/media/forwarding/temporal_test.go
@@ -8,11 +8,57 @@ import (
"time"
"github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/livekit/livekit-server/pkg/sfu/buffer"
+ "github.com/livekit/mediatransportutil/pkg/codec"
+ "github.com/livekit/protocol/livekit"
+ "github.com/livekit/protocol/logger"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
)
+// A recovered budget must not admit VP8 frames referencing discarded temporal
+// layers. Exercise the same library forwarder used by Native and SFU DownTracks.
+func TestVP8TemporalRecoveryRequiresDecodableFrames(t *testing.T) {
+ forwarder := sfu.NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil)
+ forwarder.DetermineCodec(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000}, nil,
+ livekit.VideoLayer_MODE_UNUSED)
+ forwarder.SetMaxSpatialLayer(0)
+ forwarder.SetMaxPublishedLayer(0)
+ forwarder.SetMaxTemporalLayerSeen(2)
+ for index, step := range []struct {
+ name string
+ target, tid, want int32
+ key, sync bool
+ }{
+ {"start at base layer", 0, 0, 0, true, false},
+ {"dependent frame cannot raise layer", 2, 1, 0, false, false},
+ {"sync cannot skip an intermediate layer", 2, 2, 0, false, true},
+ {"first sync restores middle layer", 2, 1, 1, false, true},
+ {"highest dependent frame still waits", 2, 2, 1, false, false},
+ {"next sync restores highest layer", 2, 2, 2, false, true},
+ {"reduce at frame boundary", 0, 2, 0, false, false},
+ {"keyframe restores all layers without sync bit", 2, 0, 2, true, false},
+ } {
+ forwarder.SetMaxTemporalLayer(step.target)
+ forwarder.AllocateOptimal([]int32{0}, sfu.Bitrates{{100_000, 200_000, 300_000}}, false, false)
+ sequence := uint64(index + 1)
+ packet := &buffer.ExtPacket{
+ VideoLayer: buffer.VideoLayer{Spatial: 0, Temporal: step.tid},
+ ExtSequenceNumber: sequence, ExtTimestamp: sequence * 3000, IsKeyFrame: step.key,
+ Packet: &rtp.Packet{Header: rtp.Header{Version: 2, Marker: true, SSRC: 1000,
+ SequenceNumber: uint16(sequence), Timestamp: uint32(sequence * 3000)}, Payload: []byte{0x10, 0}},
+ Payload: codec.VP8{S: true, T: true, TID: uint8(step.tid), Y: step.sync, IsKeyFrame: step.key},
+ }
+ if _, err := forwarder.GetTranslationParams(packet, 0); err != nil {
+ t.Fatalf("%s: %v", step.name, err)
+ }
+ if got := forwarder.CurrentLayer().Temporal; got != step.want {
+ t.Fatalf("%s: temporal layer %d, want %d", step.name, got, step.want)
+ }
+ }
+}
+
// Browser VP8 can produce L1T3 even with only one spatial encoding. Both
// forwarding owners must retain its enhancement frames, not silently cap T0.
// This exercises RTP forwarding; the Browser gate separately checks decoding.
diff --git a/internal/media/forwarding/transport.go b/internal/media/forwarding/transport.go
index af7d71ead..edaa44909 100644
--- a/internal/media/forwarding/transport.go
+++ b/internal/media/forwarding/transport.go
@@ -11,10 +11,10 @@ import (
"time"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/TNTcraftHIM/Piik/internal/thirdparty/livekit/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/livekit-server/pkg/sfu/bwe"
- "github.com/livekit/livekit-server/pkg/sfu/bwe/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu/ccutils"
"github.com/livekit/livekit-server/pkg/sfu/pacer"
"github.com/livekit/livekit-server/pkg/sfu/streamallocator"
@@ -23,7 +23,7 @@ import (
"github.com/pion/interceptor"
"github.com/pion/rtcp"
"github.com/pion/rtp"
- "github.com/pion/transport/v4/packetio"
+ "github.com/pion/transport/v5/packetio"
"github.com/pion/webrtc/v4"
)
@@ -55,7 +55,6 @@ type Transport struct {
mu sync.Mutex
attached bool
closed bool
- audioBitrate uint32
paddingReady bool
closeOnce sync.Once
closeErr error
@@ -103,7 +102,7 @@ func NewTransport(options TransportOptions) (_ *Transport, err error) {
}
}
// Preserve stock Pion SRTP/SRTCP bounds for unclaimed streams, including audio.
- input := packetio.NewBuffer()
+ input := &packetBuffer{Buffer: packetio.NewBuffer()}
limit := 1_000_000
if kind == packetio.RTCPBufferPacket {
limit = 100_000
@@ -136,11 +135,10 @@ func NewTransport(options TransportOptions) (_ *Transport, err error) {
})
transport.estimator.SetBWEListener(transport)
transport.prober = ccutils.NewProber(ccutils.ProberParams{Listener: transport, Logger: log})
- // Use the pinned Pion pacer's 5 ms service cadence, with LiveKit packet
- // ownership and a cap matching this connection's retransmission cache.
- transport.pacer = newBoundedPacer(pacer.NewLeakyBucket(log, nil, 5*time.Millisecond, options.InitialBitrate),
+ // Match LiveKit's send-side BWE default: a reduced estimate must not throttle
+ // already-encoded frames into a stale backlog after the writer recovers.
+ transport.pacer = newBoundedPacer(pacer.NewNoQueue(log, nil),
log, options.Source.maxPackets)
- transport.pacer.SetBitrate(options.InitialBitrate)
transport.pacer.sender = pacer.NewBase(log, transport.estimator)
transport.pacer.SetPacerProbeObserverListener(transport)
track, err := sfu.NewDownTrack(sfu.DownTrackParams{
@@ -155,6 +153,7 @@ func NewTransport(options TransportOptions) (_ *Transport, err error) {
return nil, err
}
transport.Output = NewOutput(track, int64(options.InitialBitrate))
+ transport.Output.feedbackObserved = transport.observed.Load
track.Receiver().(*outputReceiver).output = transport.Output
transport.Output.onActivity = transport.mediaActivity
transport.Output.onDemandChanged = options.OnDemandChanged
@@ -275,6 +274,7 @@ func validateReplacement(current, next *Source) error {
return nil
}
+// TargetBitrate reports capacity for TWCC-tracked video, not total link capacity.
func (transport *Transport) TargetBitrate() (int, bool) {
return int(transport.target.Load()), transport.observed.Load()
}
@@ -300,24 +300,8 @@ func (transport *Transport) Egress() EgressStats {
return transport.pacer.stats()
}
-// SetAudioBitrate reserves the current audio owner's contribution to this PC's
-// shared estimate. Zero means no audio demand; the transport invents no profile.
-func (transport *Transport) SetAudioBitrate(bitrate uint32) {
- transport.mu.Lock()
- defer transport.mu.Unlock()
- if !transport.closed {
- transport.audioBitrate = bitrate
- transport.refreshBudgetLocked()
- }
-}
-
func (transport *Transport) refreshBudgetLocked() {
- bitrate := int(transport.target.Load())
- remaining := int64(bitrate) - int64(transport.audioBitrate)
- if transport.probe.Id == ccutils.ProbeClusterIdInvalid || transport.probe.Result.EndTime != 0 {
- transport.pacer.SetBitrate(bitrate)
- }
- transport.Output.SetBudget(max(remaining, 0))
+ transport.Output.SetBudget(transport.target.Load())
}
func (transport *Transport) Close() error {
@@ -357,9 +341,11 @@ func (transport *Transport) OnTransportCCFeedback(_ *sfu.DownTrack, packet *rtcp
}
// HandleTWCCFeedback invokes this synchronously under the transport's event
-// lock. Store the framework result without reentering that lock.
-func (transport *Transport) OnCongestionStateChange(_, _ bwe.CongestionState, capacity int64) {
- if capacity > 0 {
+// lock. Congestion can reduce committed capacity; only a valid completed probe
+// can increase it. Warning/clear transitions can carry the estimator's initial
+// ceiling, especially while a probe is pending, rather than a new measurement.
+func (transport *Transport) OnCongestionStateChange(_, state bwe.CongestionState, capacity int64) {
+ if state == bwe.CongestionStateCongested && capacity > 0 && capacity < transport.target.Load() {
transport.target.Store(capacity)
}
}
@@ -426,8 +412,7 @@ func (transport *Transport) driveProbeLocked() {
}
if transport.probe.Result.EndTime != 0 {
if signal, capacity, finalized := transport.estimator.ProbeClusterFinalize(); finalized {
- valid := sendsidebwe.DefaultSendSideBWEConfig.CongestionDetector.ProbeSignal.IsValid(transport.probe)
- if valid && signal == ccutils.ProbeSignalNotCongesting && capacity > transport.target.Load() {
+ if signal == ccutils.ProbeSignalNotCongesting && capacity > transport.target.Load() {
transport.target.Store(capacity)
}
transport.probe = ccutils.ProbeClusterInfoInvalid
@@ -450,14 +435,12 @@ func (transport *Transport) driveProbeLocked() {
if delta <= 0 {
return
}
- usage += int64(transport.audioBitrate)
goal := ccutils.ProbeClusterGoal{AvailableBandwidthBps: int(transport.target.Load()),
ExpectedUsageBps: int(usage), DesiredBps: int(usage + max(delta*config.ProbeOveragePct/100, config.ProbeMinBps)),
Duration: transport.estimator.ProbeDuration()}
transport.probe = transport.prober.AddCluster(ccutils.ProbeClusterModeUniform, goal)
transport.estimator.ProbeClusterStarting(transport.probe)
transport.pacer.StartProbeCluster(transport.probe)
- transport.pacer.SetBitrate(goal.DesiredBps)
transport.Output.SetProbeClusterId(transport.probe.Id)
}
@@ -470,7 +453,6 @@ func (transport *Transport) stopProbeLocked() {
transport.estimator.ProbeClusterDone(transport.probe)
transport.prober.Reset(transport.probe)
transport.pacer.cancelProbes(transport.probe.Id)
- transport.pacer.SetBitrate(int(transport.target.Load()))
}
// AddCluster returns the exact cluster synchronously. Initializing it there
@@ -524,12 +506,6 @@ func newBoundedPacer(packetPacer pacer.Pacer, log logger.Logger, limit int) *bou
pending: make(map[*pacedPacket]struct{}), limit: limit}
}
-func (queue *boundedPacer) SetBitrate(bitrate int) {
- // Pion interceptor v0.1.47 gcc.LeakyBucketPacer.SetTargetBitrate uses 1.5
- // pacing headroom. Codec/allocation budgets retain the unscaled BWE result.
- queue.Pacer.SetBitrate(int(1.5 * float64(bitrate)))
-}
-
func (queue *boundedPacer) Enqueue(packet *pacer.Packet) {
queue.mu.Lock()
if queue.closed || len(queue.pending) >= queue.limit {
@@ -636,6 +612,9 @@ func (ticket *pacedPacket) WriteRTP(*rtp.Header, []byte) (int, error) {
if packet == nil {
return 0, io.ErrClosedPipe
}
+ if packet.Header.Padding {
+ packet.WriteStream = pionPaddingWriter{packet.WriteStream}
+ }
written, err := queue.sender.SendPacket(packet)
if err == nil && written > 0 {
queue.packets.Add(1)
@@ -647,6 +626,18 @@ func (ticket *pacedPacket) WriteRTP(*rtp.Header, []byte) (int, error) {
func (*pacedPacket) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
+// LiveKit's padding payload already includes its trailing padding bytes. Keep
+// that size for pacing/BWE, but let Pion serialize the padding exactly once.
+type pionPaddingWriter struct{ webrtc.TrackLocalWriter }
+
+func (writer pionPaddingWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) {
+ padding := int(header.PaddingSize)
+ if padding == 0 || padding > len(payload) {
+ return 0, errors.New("invalid forwarding padding")
+ }
+ return writer.TrackLocalWriter.WriteRTP(header, payload[:len(payload)-padding])
+}
+
type closedPacketWriter struct{}
func (closedPacketWriter) WriteRTP(*rtp.Header, []byte) (int, error) { return 0, io.ErrClosedPipe }
diff --git a/internal/media/forwarding/transport_test.go b/internal/media/forwarding/transport_test.go
index 2cb4eda68..a0d5ef4fe 100644
--- a/internal/media/forwarding/transport_test.go
+++ b/internal/media/forwarding/transport_test.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/livekit/livekit-server/pkg/sfu/bwe"
"github.com/livekit/livekit-server/pkg/sfu/ccutils"
"github.com/livekit/livekit-server/pkg/sfu/pacer"
"github.com/livekit/protocol/logger"
@@ -69,9 +70,8 @@ func TestTransportVideoFeedbackAudioAndClose(t *testing.T) {
check(err)
defer transport.Close()
transport.Output.SetMaxSpatialLayer(0)
- transport.SetAudioBitrate(64_000)
- if state := transport.Output.State(); state.VideoBudget != 936_000 {
- t.Fatalf("audio reservation did not reach the video codec budget: %+v", state)
+ if state := transport.Output.State(); state.VideoBudget != 1_000_000 {
+ t.Fatalf("audio changed the video capacity estimate: %+v", state)
}
if len(source.GetDownTracks()) != 0 {
t.Fatal("unconnected transport claimed source output")
@@ -83,7 +83,6 @@ func TestTransportVideoFeedbackAudioAndClose(t *testing.T) {
check(mediaEngine.RegisterDefaultCodecs())
registry := &interceptor.Registry{}
check(webrtc.RegisterDefaultInterceptors(mediaEngine, registry))
- check(webrtc.ConfigureTWCCSender(mediaEngine, registry))
reports := &receiverReportGate{}
registry.Add(reports)
receiver, err := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(registry),
@@ -92,6 +91,7 @@ func TestTransportVideoFeedbackAudioAndClose(t *testing.T) {
defer receiver.Close()
var videoPackets, audioPackets atomic.Uint32
firstVideo := make(chan *rtp.Packet, 1)
+ paddingPackets := make(chan *rtp.Packet, 8)
var recoveryTimestamp atomic.Uint32
recovered := make(chan struct{}, 1)
var readers sync.WaitGroup
@@ -103,6 +103,12 @@ func TestTransportVideoFeedbackAudioAndClose(t *testing.T) {
if readErr != nil {
return
}
+ if packet.Padding {
+ select {
+ case paddingPackets <- packet:
+ default:
+ }
+ }
if len(packet.Payload) == 0 {
continue
}
@@ -244,6 +250,19 @@ func TestTransportVideoFeedbackAudioAndClose(t *testing.T) {
if ready || transport.prober.IsRunning() {
t.Fatal("transport probed before a receiver report")
}
+ // Exercise the library's real padding packet through Pion SRTP. Probe bytes
+ // must be stripped by RTP decoding, not exposed to the video decoder as media.
+ if transport.Output.WritePaddingRTP(255, true, false) == 0 {
+ t.Fatal("padding fixture did not emit a probe")
+ }
+ select {
+ case packet := <-paddingPackets:
+ if len(packet.Payload) != 0 || packet.Header.PaddingSize == 0 {
+ t.Fatalf("padding reached the receiver as video: payload=%d padding=%d", len(packet.Payload), packet.Header.PaddingSize)
+ }
+ case <-ctx.Done():
+ t.Fatal("padding packet did not arrive")
+ }
// A muted library sender returns zero padding bytes despite a connected PC
// and valid RR. Its probe must finish, and inconclusive feedback must not
// promote the BWE's internal initial-capacity sentinel into a codec budget.
@@ -324,6 +343,97 @@ func (writer *countingPacketWriter) WriteRTP(header *rtp.Header, payload []byte)
func (*countingPacketWriter) Write([]byte) (int, error) { panic("unexpected byte write") }
+func TestTransportDoesNotPromoteUnverifiedCongestionCapacity(t *testing.T) {
+ transport := &Transport{}
+ transport.target.Store(1_200_000)
+ // A pending probe can leave the framework's initial 100 Mbps estimate in
+ // state-change callbacks. It is not proof that the connection can send it.
+ transport.OnCongestionStateChange(bwe.CongestionStateNone, bwe.CongestionStateEarlyWarning, 100_000_000)
+ transport.OnCongestionStateChange(bwe.CongestionStateEarlyWarning, bwe.CongestionStateCongested, 100_000_000)
+ if bitrate, _ := transport.TargetBitrate(); bitrate != 1_200_000 {
+ t.Fatalf("congestion promoted an unverified capacity: %d", bitrate)
+ }
+ transport.OnCongestionStateChange(bwe.CongestionStateCongested, bwe.CongestionStateCongested, 500_000)
+ transport.OnCongestionStateChange(bwe.CongestionStateCongested, bwe.CongestionStateNone, 800_000)
+ if bitrate, _ := transport.TargetBitrate(); bitrate != 500_000 {
+ t.Fatalf("clearing congestion replaced the committed reduction without a probe: %d", bitrate)
+ }
+}
+
+type stalledPacketWriter struct {
+ ready, release chan struct{}
+ written chan struct{}
+ once sync.Once
+}
+
+func (writer *stalledPacketWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) {
+ writer.once.Do(func() { close(writer.ready); <-writer.release })
+ writer.written <- struct{}{}
+ return header.MarshalSize() + len(payload), nil
+}
+
+func (*stalledPacketWriter) Write([]byte) (int, error) { panic("unexpected byte write") }
+
+func TestSendQueuesDrainAfterBandwidthReduction(t *testing.T) {
+ for _, kind := range []string{"transport", "publication"} {
+ t.Run(kind, func(t *testing.T) {
+ codec := webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{
+ MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000,
+ }, PayloadType: 96}
+ source, err := NewSource(SourceOptions{ID: "queue", StreamID: "queue", Codec: codec,
+ Formats: []LayerFormat{{Width: 320, Height: 180, Bitrate: 500_000}}, MaxPackets: 500})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer source.Close()
+ options := TransportOptions{Source: source, ConnectionID: "queue", InitialBitrate: 500_000}
+ var queue *boundedPacer
+ if kind == "publication" {
+ publication, err := NewPublication(options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer publication.Close()
+ queue = publication.pacer.boundedPacer
+ } else {
+ transport, err := NewTransport(options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ queue = transport.pacer
+ }
+ writer := &stalledPacketWriter{ready: make(chan struct{}), release: make(chan struct{}), written: make(chan struct{}, 32)}
+ release := sync.OnceFunc(func() { close(writer.release) })
+ defer release()
+ for index := range 32 {
+ header := &rtp.Header{Version: 2, SSRC: 1, SequenceNumber: uint16(index)}
+ queue.Enqueue(&pacer.Packet{Header: header, HeaderSize: header.MarshalSize(),
+ Payload: make([]byte, 1200), WriteStream: writer})
+ }
+ select {
+ case <-writer.ready:
+ case <-time.After(2 * time.Second):
+ t.Fatal("transport did not reach the stalled writer")
+ }
+ // A short outage reduces the bandwidth estimate after these packets were
+ // encoded. The recovered writer must not spend tens of seconds sending old
+ // video before it can deliver a new frame.
+ queue.SetBitrate(8_000)
+ release()
+ deadline := time.NewTimer(2 * time.Second)
+ defer deadline.Stop()
+ for range 32 {
+ select {
+ case <-writer.written:
+ case <-deadline.C:
+ t.Fatalf("writable transport retained old video: pending=%d sent=%d", queue.pendingCount(), queue.stats().Packets)
+ }
+ }
+ })
+ }
+}
+
func TestBoundedPacerReleasesPacketsAndIsolatesBlockedQueue(t *testing.T) {
log := logger.GetDiscardLogger()
sender := pacer.NewPassThrough(log, nil)
diff --git a/internal/media/forwarding/warm_source_test.go b/internal/media/forwarding/warm_source_test.go
new file mode 100644
index 000000000..9143eb2bf
--- /dev/null
+++ b/internal/media/forwarding/warm_source_test.go
@@ -0,0 +1,137 @@
+package forwarding
+
+import (
+ "context"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/media/encoded"
+ "github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/pion/interceptor"
+ "github.com/pion/webrtc/v4"
+)
+
+// A late child must establish real video/TWCC even when its initial estimate
+// is below the source's measured rate. Payloads exercise RTP, not decoding.
+func TestTransportBootstrapsWarmRawSource(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
+ defer cancel()
+ check := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ wait := func(done <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-done:
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ }
+ codec := webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{
+ MimeType: webrtc.MimeTypeVP8, ClockRate: 90_000,
+ RTCPFeedback: []webrtc.RTCPFeedback{{Type: "nack"}, {Type: "nack", Parameter: "pli"}, {Type: webrtc.TypeRTCPFBTransportCC}},
+ }, PayloadType: 96}
+ source, err := NewEncodedSource(SourceOptions{ID: "warm", StreamID: "fixture", Codec: codec,
+ Formats: []LayerFormat{{Width: 8, Height: 8, Bitrate: 5_000_000}}})
+ check(err)
+ done, producerError := make(chan struct{}), make(chan error, 1)
+ go func() {
+ defer close(done)
+ ticker := time.NewTicker(time.Second / 30)
+ defer ticker.Stop()
+ data := make([]byte, 20_000)
+ copy(data, sfu.VP8KeyFrame8x8)
+ for frame := 0; ; frame++ {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-ticker.C:
+ pts := time.Duration(frame) * (time.Second / 30)
+ if err := source.BeginFrame(pts, now); err != nil {
+ producerError <- err
+ return
+ }
+ if err := source.WriteFrame(0, encoded.Frame{Data: data, PTS: pts, Duration: time.Second / 30}); err != nil {
+ producerError <- err
+ return
+ }
+ }
+ }
+ }()
+ defer func() { cancel(); <-done; source.Close() }()
+ for {
+ available, rates := source.GetLayeredBitrate()
+ if len(available) > 0 && rates[0][0] > 4_000_000 {
+ break
+ }
+ select {
+ case err := <-producerError:
+ t.Fatal(err)
+ case <-ctx.Done():
+ t.Fatal("source did not establish its measured bitrate")
+ case <-time.After(20 * time.Millisecond):
+ }
+ }
+ settings := webrtc.SettingEngine{}
+ settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+ settings.SetIncludeLoopbackCandidate(true)
+ settings.SetIPFilter(func(ip net.IP) bool { return ip.IsLoopback() })
+ transport, err := NewTransport(TransportOptions{Source: source.Source, Settings: settings,
+ ConnectionID: "late-child", InitialBitrate: 3_000_000})
+ check(err)
+ defer transport.Close()
+ media := &webrtc.MediaEngine{}
+ check(media.RegisterDefaultCodecs())
+ registry := &interceptor.Registry{}
+ check(webrtc.RegisterDefaultInterceptors(media, registry))
+ receiver, err := webrtc.NewAPI(webrtc.WithMediaEngine(media), webrtc.WithInterceptorRegistry(registry),
+ webrtc.WithSettingEngine(settings)).NewPeerConnection(webrtc.Configuration{})
+ check(err)
+ defer receiver.Close()
+ var packets atomic.Uint32
+ receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
+ for {
+ packet, _, err := track.ReadRTP()
+ if err != nil {
+ return
+ }
+ if len(packet.Payload) > 0 {
+ packets.Add(1)
+ }
+ }
+ })
+ offer, err := transport.PC.CreateOffer(nil)
+ check(err)
+ gathered := webrtc.GatheringCompletePromise(transport.PC)
+ check(transport.PC.SetLocalDescription(offer))
+ wait(gathered)
+ check(receiver.SetRemoteDescription(*transport.PC.LocalDescription()))
+ answer, err := receiver.CreateAnswer(nil)
+ check(err)
+ gathered = webrtc.GatheringCompletePromise(receiver)
+ check(receiver.SetLocalDescription(answer))
+ wait(gathered)
+ check(transport.PC.SetRemoteDescription(*receiver.LocalDescription()))
+ for transport.PC.ConnectionState() != webrtc.PeerConnectionStateConnected {
+ select {
+ case <-time.After(5 * time.Millisecond):
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ }
+ check(transport.SetConnected())
+ for packets.Load() == 0 || !transport.observed.Load() {
+ select {
+ case err := <-producerError:
+ t.Fatal(err)
+ case <-ctx.Done():
+ t.Fatalf("warm source could not establish video/feedback: packets=%d state=%+v", packets.Load(), transport.Output.State())
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+}
diff --git a/internal/server/app/access.go b/internal/server/app/access.go
index f2a2b1ec8..ae5451052 100644
--- a/internal/server/app/access.go
+++ b/internal/server/app/access.go
@@ -68,7 +68,7 @@ func (s *Server) siteAccessForRequest(request *http.Request) *siteAccessGate {
return &access
}
-// newSiteAccess mirrors the SiteAccess constructor, including its TTL check.
+// newSiteAccess creates one process-scoped cookie authority.
func newSiteAccess(options siteAccessOptions) (*siteAccessGate, error) {
ttlSeconds := options.TTLSeconds
if ttlSeconds == 0 {
@@ -100,13 +100,12 @@ func (a *siteAccessGate) required() bool {
return a.password != ""
}
-// passwordMatches mirrors passwordMatches: an unset password accepts anything,
-// a set password needs a non-empty exact match.
+// An unset password permits entry; a configured password requires an exact match.
func (a *siteAccessGate) passwordMatches(value string) bool {
return !a.required() || (value != "" && secretMatches(value, a.password))
}
-// isAuthenticated mirrors isAuthenticated over a raw Cookie request header.
+// isAuthenticated verifies the cookie signature and expiry when access is required.
func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool {
if !a.required() {
return true
@@ -134,8 +133,7 @@ func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool {
return secretMatches(segments[2], a.sign(segments[0]+"."+segments[1]))
}
-// createCookie mirrors createCookie and returns the Set-Cookie header value,
-// or "" where TS returned undefined (site access is not required).
+// createCookie returns a Set-Cookie value, or "" when access is not required.
func (a *siteAccessGate) createCookie() string {
if !a.required() {
return ""
@@ -143,7 +141,6 @@ func (a *siteAccessGate) createCookie() string {
expiresAt := floorSeconds(a.now()) + int64(a.ttlSeconds)
payload := cookieVersion + "." + strconv.FormatInt(expiresAt, 10)
value := payload + "." + a.sign(payload)
- // Attribute order is the TS array order; the app writes it verbatim.
attributes := []string{
a.cookieName() + "=" + value,
"Path=/",
@@ -180,8 +177,7 @@ func floorSeconds(milliseconds int64) int64 {
return seconds
}
-// readCookie mirrors the TS reader: the first part whose trimmed name matches
-// wins, and an empty value counts as absent.
+// readCookie uses the first matching name; an empty value counts as absent.
func readCookie(header string, name string) string {
for _, part := range strings.Split(header, ";") {
separator := strings.IndexByte(part, '=')
@@ -193,7 +189,7 @@ func readCookie(header string, name string) string {
}
// secretMatches compares through SHA-256 digests so the comparison is constant
-// time regardless of length, exactly as the TS helper does.
+// time regardless of length.
func secretMatches(actual string, expected string) bool {
actualDigest := sha256.Sum256([]byte(actual))
expectedDigest := sha256.Sum256([]byte(expected))
diff --git a/internal/server/app/app.go b/internal/server/app/app.go
index 1255087f0..f507d2725 100644
--- a/internal/server/app/app.go
+++ b/internal/server/app/app.go
@@ -30,6 +30,7 @@ import (
"time"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/TNTcraftHIM/Piik/internal/server/config"
"github.com/TNTcraftHIM/Piik/internal/server/room"
"github.com/TNTcraftHIM/Piik/internal/server/sfu"
@@ -41,21 +42,19 @@ import (
// HTTP request, header and keep-alive deadlines.
const (
- requestTimeout = 10 * time.Second // httpServer.requestTimeout
- headersTimeout = 15 * time.Second // httpServer.headersTimeout
- keepAliveTimeout = 5 * time.Second // httpServer.keepAliveTimeout
+ requestTimeout = 10 * time.Second
+ headersTimeout = 15 * time.Second
+ keepAliveTimeout = 5 * time.Second
)
-// Options is CreateServerOptions. Every zero value means the TS `undefined`.
+// Options supplies service configuration and optional lifecycle dependencies.
type Options struct {
// Config is the loaded server configuration; cmd owns config.Load / Local.
Config config.Config
// Listener is optional prebound TCP ownership transferred on successful New.
- // It lets the Local Client reserve its HTTP port before opening a tunnel.
+ // It lets the Local App reserve its HTTP port before opening a tunnel.
Listener *net.TCPListener
- // Assets is the built Browser UI. A nil FS serves the API only, which is
- // the TS frontend mode "none"; webassets.FS() returns nil the same way
- // when no build was embedded.
+ // Assets is the built Browser UI. A nil FS serves the API only.
Assets fs.FS
// Now returns Unix milliseconds; nil uses the wall clock.
Now func() int64
@@ -75,7 +74,7 @@ type Options struct {
RoomStore *room.Store
}
-// Server is PiikServer.
+// Server owns one shared HTTP, room-authority and media service lifecycle.
type Server struct {
config config.Config
store *room.Store
@@ -108,9 +107,7 @@ type Server struct {
shutdownErr error
}
-// New is createPiikServer minus the listening: it builds the SFU fallback,
-// the room store, the site-access issuer, the frontend handler and the HTTP
-// server, none of which performs I/O.
+// New composes the service without binding sockets or opening the room database.
func New(options Options) (*Server, error) {
configuration := options.Config
now := options.Now
@@ -156,9 +153,7 @@ func New(options Options) (*Server, error) {
EndpointMediaCopyCapacity: configuration.EndpointMediaCopyCapacity,
SfuOnly: configuration.SFU != nil && configuration.SFU.Only,
Ice: config.IceConfig(configuration),
- // The TS signaling server read natPredictionEnabled off the same
- // `ice` options object it forwarded; the Go IceConfig is already the
- // wire shape, so the capability travels beside it.
+ // The prediction capability is separate from the wire-shaped ICE settings.
NATPredictionEnabled: configuration.NATPredictionEnabled,
AllowedOrigins: configuration.AllowedOrigins,
SiteAccessAtUpgrade: func(request *http.Request) bool {
@@ -180,13 +175,13 @@ func New(options Options) (*Server, error) {
ReadHeaderTimeout: headersTimeout,
ReadTimeout: requestTimeout,
IdleTimeout: keepAliveTimeout,
- // No WriteTimeout: Node had none, and it would cut long downloads.
+ // No WriteTimeout: a fixed deadline would cut long asset downloads.
}
return server, nil
}
-// newRoomStore builds the room store createPiikServer would otherwise take
-// from Options.RoomStore. An empty RoomDatabasePath is the memory-only mode.
+// newRoomStore uses the injected store or creates one from configuration.
+// An empty RoomDatabasePath selects memory-only authority.
func newRoomStore(options Options) (*room.Store, error) {
if options.RoomStore != nil {
return options.RoomStore, nil
@@ -256,7 +251,7 @@ func (s *Server) start(ctx context.Context) (int, error) {
return 0, fmt.Errorf("Piik SFU listener failed: %w", listenErr)
}
factory := diagnostics.PionLoggerFactory()
- s.mediaMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: connection, Logger: factory.NewLogger("sfu-udp")})
+ s.mediaMux = forwarding.NewUDPMux(ice.UDPMuxParams{UDPConn: connection, Logger: factory.NewLogger("sfu-udp")})
settings := webrtc.SettingEngine{LoggerFactory: factory}
settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
settings.SetICEUDPMux(s.mediaMux)
@@ -332,9 +327,8 @@ func (s *Server) reconcile() error {
if err := s.store.Initialize(); err != nil {
return err
}
- // Holding mu across the check and the two stores is what made the TS
- // single thread safe here: a shutdown that already set closing must never
- // see traffic re-enabled behind it.
+ // Serialize publication with shutdown; once closing is set, startup must
+ // never re-enable traffic.
s.mu.Lock()
defer s.mu.Unlock()
if s.closing {
@@ -355,14 +349,13 @@ func (s *Server) Close(ctx context.Context) error {
return s.shutdown(ctx, false)
}
-// End is Close preceded by ending every room. The Client uses it, because its
+// End is Close preceded by ending every room. The Local App uses it, because its
// authority dies with the process.
func (s *Server) End(ctx context.Context) error {
return s.shutdown(ctx, true)
}
-// shutdown is memoised exactly as the TS shutdownOperation was: the second
-// caller joins the first and gets its result, whichever entry point it used.
+// Every shutdown caller joins the first operation and receives the same result.
func (s *Server) shutdown(ctx context.Context, endRooms bool) error {
s.shutdownOnce.Do(func() { s.shutdownErr = s.runShutdown(ctx, endRooms) })
return s.shutdownErr
@@ -391,16 +384,19 @@ func (s *Server) runShutdown(ctx context.Context, endRooms bool) error {
// The signaling server must be closed before the listener: its connections
// live on it.
failures = append(failures, s.stopServing(ctx))
- failures = append(failures, s.store.Close())
+ if signaling != nil {
+ failures = append(failures, signaling.CloseStorage())
+ } else {
+ failures = append(failures, s.store.Close())
+ }
if joined := errors.Join(failures...); joined != nil {
return fmt.Errorf("Piik server shutdown failed: %w", joined)
}
return nil
}
-// endAllRooms is the TS `signaling ? signaling.endAllRooms() :
-// roomStore.abandonAllRooms()`: without a signaling server nobody is connected,
-// so the store can be closed down directly.
+// Without signaling no participant is connected, so room authority can be
+// retired directly; otherwise signaling owns participant and media teardown.
func (s *Server) endAllRooms(signaling *signal.Server) error {
if signaling != nil {
return signaling.EndAllRooms()
diff --git a/internal/server/app/app_test.go b/internal/server/app/app_test.go
index e0b9347d3..8533421b0 100644
--- a/internal/server/app/app_test.go
+++ b/internal/server/app/app_test.go
@@ -366,7 +366,7 @@ func TestCapabilitiesReportServicesDisabledByDefault(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/api/capabilities").
- expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
+ expect(http.StatusOK, `{"sfu":false,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
}
@@ -376,7 +376,7 @@ func TestCapabilitiesReportSFUOnlyPolicy(t *testing.T) {
configuration.NATPredictionEnabled = true
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/capabilities").
- expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":true,"sfuOnly":true,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
+ expect(http.StatusOK, `{"sfu":true,"sfuOnly":true,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
}
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
@@ -387,7 +387,7 @@ func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/capabilities").
- expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":true,"roomInteractions":true,"hostRoomSession":true}`).
+ expect(http.StatusOK, `{"sfu":false,"natPrediction":true,"roomInteractions":true,"hostRoomSession":true}`).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "nosniff")
@@ -430,7 +430,7 @@ func TestSFUListenerFollowsApplicationCloseAndEnd(t *testing.T) {
t.Fatal("missing runtime owner")
}
server.do(http.MethodGet, "/api/capabilities").
- expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":true,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
+ expect(http.StatusOK, `{"sfu":true,"natPrediction":false,"roomInteractions":true,"hostRoomSession":true}`)
var err error
if ending {
diff --git a/internal/server/app/json.go b/internal/server/app/json.go
index c5affdc74..5226ba0c1 100644
--- a/internal/server/app/json.go
+++ b/internal/server/app/json.go
@@ -35,7 +35,7 @@ type siteAccessBody struct {
func sendJSON(writer http.ResponseWriter, status int, body any) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
- // JSON.stringify does not escape HTML either.
+ // Responses have a JSON content type and are never embedded into HTML.
encoder.SetEscapeHTML(false)
if err := encoder.Encode(body); err != nil {
panic(err)
@@ -75,21 +75,13 @@ func hasRequestBody(request *http.Request) bool {
return len(request.TransferEncoding) > 0 || request.ContentLength != 0
}
-// requestPath is the WHATWG `url.pathname` app.ts routed and logged on. It is
-// the escaped path on purpose: `new URL()` does not percent-decode, so
-// "/api%2frooms" must not reach the /api/rooms route.
-//
-// Deviation: `new URL()` also resolves "." and ".." segments, which net/http
-// leaves in the path. "/x/../api/rooms" therefore reaches the frontend handler
-// here where TypeScript routed it to room creation. Only stricter: a raw path
-// that already names a route still matches, so nothing can slip past an origin,
-// cookie or token check that way.
+// requestPath matches API routes without decoding separators or normalizing
+// dot segments: "/api%2frooms" and "/x/../api/rooms" cannot alias /api/rooms.
func requestPath(request *http.Request) string {
return request.URL.EscapedPath()
}
-// cookieHeader rebuilds the single Cookie header value the Node parser handed
-// to access-session.ts; Go keeps repeated Cookie headers apart.
+// cookieHeader joins repeated Cookie headers before the shared cookie reader.
func cookieHeader(request *http.Request) string {
return strings.Join(request.Header.Values("Cookie"), "; ")
}
@@ -104,10 +96,8 @@ func bearerToken(request *http.Request) string {
return strings.TrimPrefix(authorization, "Bearer ")
}
-// isUpgradeRequest reproduces the condition under which Node emitted "upgrade"
-// instead of "request": the parser needs both a Connection: upgrade token and
-// an Upgrade header. The SignalingServer, not this router, owns the rejection
-// ladder for a path or query that is not exactly /signal.
+// Upgrades require both a Connection: upgrade token and an Upgrade header.
+// Signaling owns validation of the exact /signal path, query and admission.
func isUpgradeRequest(request *http.Request) bool {
if request.Header.Get("Upgrade") == "" {
return false
@@ -120,8 +110,8 @@ func isUpgradeRequest(request *http.Request) bool {
return false
}
-// responseRecorder tracks response.headersSent so the recover wrapper can pick
-// between the JSON 500 and destroying the connection, as app.ts did.
+// responseRecorder lets panic recovery send JSON only before a response starts;
+// a partial response must instead abort the connection.
type responseRecorder struct {
http.ResponseWriter
wrote bool
diff --git a/internal/server/app/routes.go b/internal/server/app/routes.go
index 278f51820..6d1655781 100644
--- a/internal/server/app/routes.go
+++ b/internal/server/app/routes.go
@@ -48,8 +48,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}
signaling := s.signaling.Load()
if isUpgradeRequest(request) {
- // Node routed every upgrade to the SignalingServer's "upgrade"
- // listener, which was attached only while traffic was accepted.
+ // Signaling owns every upgrade, including rejection of invalid paths.
signaling.ServeHTTP(recorder, request)
return
}
@@ -78,12 +77,11 @@ func (s *Server) route(
return
}
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
- ConnectionAttemptProgress4: true,
- Sfu: s.config.SFU != nil,
- SfuOnly: s.config.SFU != nil && s.config.SFU.Only,
- NatPrediction: s.config.NATPredictionEnabled && !(s.config.SFU != nil && s.config.SFU.Only),
- RoomInteractions: true,
- HostRoomSession: true,
+ Sfu: s.config.SFU != nil,
+ SfuOnly: s.config.SFU != nil && s.config.SFU.Only,
+ NatPrediction: s.config.NATPredictionEnabled && !(s.config.SFU != nil && s.config.SFU.Only),
+ RoomInteractions: true,
+ HostRoomSession: true,
})
return
}
@@ -122,8 +120,7 @@ func (s *Server) route(
notFoundJSON(writer, request)
}
-// notFoundJSON is the fallthrough app.ts handed to sirv as its next()
-// callback and used when no frontend is composed at all.
+// notFoundJSON handles missing assets and requests without a composed frontend.
func notFoundJSON(writer http.ResponseWriter, _ *http.Request) {
sendJSON(writer, http.StatusNotFound, errorBody{"Not found"})
}
@@ -145,7 +142,7 @@ func allowMethod(writer http.ResponseWriter, request *http.Request, allowed stri
return false
}
-// handleSiteAccess is handleSiteAccessRequest of app.ts.
+// handleSiteAccess reports or grants site access without claiming room authority.
func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Request) {
noStoreJSON(writer)
access := s.siteAccessForRequest(request)
@@ -156,8 +153,7 @@ func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Requ
Authenticated: access.isAuthenticated(cookieHeader(request)),
}
if status.Required && status.Authenticated {
- // The Set-Cookie value is written verbatim, in the TS attribute
- // order.
+ // Authenticated visits renew the site's idle access lifetime.
writer.Header().Set("Set-Cookie", access.createCookie())
}
sendJSON(writer, http.StatusOK, status)
@@ -312,13 +308,8 @@ func (s *Server) handleRoomCreation(
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room request"})
return
}
- // TS called roomStore.createRoom directly; the store is now guarded by the
- // signaling server's global mutex, so creation goes through it. There is
- // no `signaling == nil` answer here, because app.ts had no getSignaling()
- // check on this route: reconcile stores the signaling server before it
- // sets acceptingTraffic and never clears it again, so reaching a handler
- // at all means the pointer is set (the two sibling room routes keep their
- // 503 only because the TypeScript had one).
+ // Signaling owns room mutations. Startup publishes it before accepting
+ // traffic, and shutdown retains the pointer for requests already admitted.
created, err := signaling.CreateRoom(
parsed.CodeEntryPolicy,
parsed.RoomPassword.Value,
@@ -335,8 +326,8 @@ func (s *Server) handleRoomCreation(
sendJSON(writer, http.StatusCreated, s.createRoomResponse(created))
}
-// authorizedRoomHostToken of app.ts: origin, then site access, then the bearer
-// Host token, each with its own terminal response.
+// authorizedRoomHostToken checks origin, site access and the bearer Host token
+// in that order, each with its own terminal response.
func (s *Server) authorizedRoomHostToken(
writer http.ResponseWriter,
request *http.Request,
@@ -424,8 +415,7 @@ func roomErrorCode(err error) room.ErrorCode {
return ""
}
-// replacementFailure maps replaceRoom errors; an unmapped error rethrows into
-// the 500 handler, as the TS `throw error` did.
+// replacementFailure maps room replacement errors; unmapped errors reach the 500 handler.
func replacementFailure(err error) (int, string, bool) {
switch roomErrorCode(err) {
case room.CodeRoomLimit:
@@ -439,7 +429,7 @@ func replacementFailure(err error) (int, string, bool) {
}
// accessFailure maps updateRoomAccess errors. Unlike the other two tables,
-// every remaining RoomStoreError is a 404; only a non-store error rethrows.
+// every remaining RoomStoreError is a 404; non-store errors reach the 500 handler.
func accessFailure(err error) (int, string, bool) {
switch code := roomErrorCode(err); code {
case "":
diff --git a/internal/server/app/shutdown_test.go b/internal/server/app/shutdown_test.go
new file mode 100644
index 000000000..be9457884
--- /dev/null
+++ b/internal/server/app/shutdown_test.go
@@ -0,0 +1,120 @@
+package app
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "io"
+ "net/http"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/server/room"
+)
+
+type admittedBody struct {
+ io.ReadCloser
+ beforeRead func()
+}
+
+func (body *admittedBody) Read(p []byte) (int, error) {
+ if before := body.beforeRead; before != nil {
+ body.beforeRead = nil
+ before()
+ }
+ return body.ReadCloser.Read(p)
+}
+
+func TestShutdownDoesNotCloseStorageDuringAnAdmittedRoomWrite(t *testing.T) {
+ admitted, readBody := make(chan struct{}), make(chan struct{})
+ mutating, finishMutation := make(chan struct{}), make(chan struct{})
+ for _, gate := range []chan struct{}{readBody, finishMutation} {
+ defer func() {
+ select {
+ case <-gate:
+ default:
+ close(gate)
+ }
+ }()
+ }
+ database, err := room.NewDatabase(filepath.Join(t.TempDir(), "rooms.sqlite"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ store, err := room.New(room.Options{
+ MaxRooms: room.Capacity, MaxViewersPerRoom: 20, Database: database,
+ Random: func(size int) []byte {
+ // Hold an actual creation inside the signaling-owned store mutation.
+ if size == 32 {
+ close(mutating)
+ <-finishMutation
+ }
+ value := make([]byte, size)
+ _, _ = rand.Read(value)
+ return value
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ configuration := testConfig(t)
+ configuration.SiteAccessPassword = ""
+ server := newServer(t, Options{Config: configuration, RoomStore: store})
+ server.httpServer.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ request.Body = &admittedBody{ReadCloser: request.Body, beforeRead: func() {
+ close(admitted)
+ <-readBody
+ }}
+ server.ServeHTTP(writer, request)
+ })
+ port, err := server.Listen(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := &harness{Server: server, t: t, baseURL: baseURL(port), client: testClient(t)}
+ response := make(chan reply, 1)
+ go func() { response <- h.createRoom(roomRequest{codeEntryPolicy: "open"}) }()
+ <-admitted
+
+ // HTTP draining starts after signaling closes. An already admitted body
+ // can still finish and enter a store mutation during that drain.
+ draining := make(chan struct{})
+ server.httpServer.RegisterOnShutdown(func() { close(draining) })
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ closed := make(chan error, 1)
+ go func() { closed <- server.Close(ctx) }()
+ <-draining
+ close(readBody)
+ <-mutating
+ cancel()
+ var closeErr error
+ returned := false
+ select {
+ case closeErr = <-closed:
+ returned = true
+ case <-time.After(100 * time.Millisecond):
+ }
+ close(finishMutation)
+ result := <-response
+ if !returned {
+ closeErr = <-closed
+ }
+ if returned {
+ t.Error("shutdown closed room storage while an admitted mutation still owned it")
+ }
+ if !errors.Is(closeErr, context.Canceled) {
+ t.Fatalf("shutdown error = %v, want the HTTP drain cancellation", closeErr)
+ }
+ result.expectStatus(http.StatusCreated)
+
+ restored, err := database.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ if len(restored) != 1 || restored[0].RoomID != result.room().RoomID {
+ t.Fatalf("the accepted write was not restored: got %d rooms", len(restored))
+ }
+}
diff --git a/internal/server/app/static.go b/internal/server/app/static.go
index 9c17f1171..24aaa83b3 100644
--- a/internal/server/app/static.go
+++ b/internal/server/app/static.go
@@ -16,7 +16,7 @@ import (
// depend on the machine; with X-Content-Type-Options: nosniff a wrong type on a
// font or a webmanifest is user visible.
var staticContentTypes = map[string]string{
- ".html": "text/html;charset=utf-8", // sirv appends ";charset=utf-8" with no space
+ ".html": "text/html;charset=utf-8",
".htm": "text/html;charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
@@ -39,7 +39,7 @@ var staticContentTypes = map[string]string{
".webmanifest": "application/manifest+json",
".bin": "application/octet-stream",
".xml": "text/xml",
- ".ico": "", // mrmime has no .ico, so sirv sends an EMPTY Content-Type
+ ".ico": "", // Preserve the published empty type without sniffing.
}
// staticHandler serves embedded assets and falls back to index.html for SPA
@@ -78,13 +78,11 @@ func staticHandler(assets fs.FS, notFound http.Handler) http.Handler {
return true
}
- // sirv strips a trailing "/" before the lookup, and its FILES map holds
- // files only, so "/assets/" can never hit a file.
+ // A trailing slash cannot name an asset file.
if !strings.HasSuffix(request.URL.Path, "/") && serve(name) {
return
}
- // opts.single: fall back to index.html unless the request matches one
- // of sirv's two default ignores.
+ // Route-like paths use the SPA; files and .well-known requests do not.
if !looksLikeFile(cleaned) && !strings.Contains(cleaned, "/.well-known") {
if serve("index.html") {
return
@@ -94,9 +92,7 @@ func staticHandler(assets fs.FS, notFound http.Handler) http.Handler {
})
}
-// hasDotSegment reports whether any path segment starts with '.', reproducing
-// totalist's filter `!opts.dotfiles && /(^\.|[\\+|\/+]\.)/.test(name)` together
-// with the `/\.well-known[\\+\/]/` exemption that runs before it.
+// hasDotSegment excludes hidden assets, with the published .well-known exemption.
func hasDotSegment(name string) bool {
if strings.Contains(name, ".well-known/") {
return false
@@ -109,13 +105,9 @@ func hasDotSegment(name string) bool {
return false
}
-// looksLikeFile ports sirv's default "any extn" ignore,
-// /[/]([A-Za-z\s\d~$._-]+\.\w+){1,}$/. Because the character class excludes
-// "/" and the match is anchored at the end, only the last path segment can
-// match: it must contain a dot with a non-empty [A-Za-z0-9_] extension after
-// it, and everything before that dot must be in the class. The class is
-// ASCII-only, so "/文件.js" and "/café.css" are NOT ignored and do get the
-// index.html fallback.
+// looksLikeFile preserves the published SPA fallback boundary: the last path
+// segment needs an ASCII-style basename and a nonempty alphanumeric/underscore
+// extension. Names such as "/文件.js" and "/café.css" still reach the SPA.
func looksLikeFile(pathname string) bool {
slash := strings.LastIndexByte(pathname, '/')
if slash < 0 {
diff --git a/internal/server/protocol/client.go b/internal/server/protocol/client.go
index ae318ab96..ba004829c 100644
--- a/internal/server/protocol/client.go
+++ b/internal/server/protocol/client.go
@@ -13,35 +13,33 @@ type ClientMessage interface {
// AuthenticateHostMessage is the host member of authenticateMessageSchema.
type AuthenticateHostMessage struct {
- Type string `json:"type"`
- Protocol string `json:"protocol"`
- RoomID string `json:"roomId"`
- Role Role `json:"role"`
- Token string `json:"token"`
- ClientID string `json:"clientId"`
- ShareGeneration string `json:"shareGeneration,omitempty"`
- SharingPaused *bool `json:"sharingPaused,omitempty"`
- QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
- RoutePolicy RoutePolicy `json:"routePolicy"`
- ViewerPresence bool `json:"viewerPresence,omitempty"`
- ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
- DisplayName *DisplayName `json:"displayName,omitempty"`
- RoomOnly bool `json:"roomOnly,omitempty"`
- RoomSession bool `json:"roomSession,omitempty"`
+ Type string `json:"type"`
+ Protocol string `json:"protocol"`
+ RoomID string `json:"roomId"`
+ Role Role `json:"role"`
+ Token string `json:"token"`
+ ClientID string `json:"clientId"`
+ ShareGeneration string `json:"shareGeneration,omitempty"`
+ SharingPaused *bool `json:"sharingPaused,omitempty"`
+ QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
+ RoutePolicy RoutePolicy `json:"routePolicy"`
+ ViewerPresence bool `json:"viewerPresence,omitempty"`
+ DisplayName *DisplayName `json:"displayName,omitempty"`
+ RoomOnly bool `json:"roomOnly,omitempty"`
+ RoomSession bool `json:"roomSession,omitempty"`
}
// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema.
type AuthenticateViewerMessage struct {
- Type string `json:"type"`
- Protocol string `json:"protocol"`
- RoomID string `json:"roomId"`
- Role Role `json:"role"`
- ClientID string `json:"clientId"`
- ViewerGrant string `json:"viewerGrant,omitempty"`
- ViewerPassword string `json:"viewerPassword,omitempty"`
- DisplayName *DisplayName `json:"displayName,omitempty"`
- ViewerPresence bool `json:"viewerPresence,omitempty"`
- ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
+ Type string `json:"type"`
+ Protocol string `json:"protocol"`
+ RoomID string `json:"roomId"`
+ Role Role `json:"role"`
+ ClientID string `json:"clientId"`
+ ViewerGrant string `json:"viewerGrant,omitempty"`
+ ViewerPassword string `json:"viewerPassword,omitempty"`
+ DisplayName *DisplayName `json:"displayName,omitempty"`
+ ViewerPresence bool `json:"viewerPresence,omitempty"`
}
// SignalingChallengeMessage is { type: "signaling-challenge", sequence }.
@@ -301,7 +299,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
return nil, err
}
if err := present.optional("shareGeneration", "roomOnly", "roomSession", "sharingPaused", "qualitySettings",
- "routePolicy", "viewerPresence", "displayName", "connectionAttemptProgress4"); err != nil {
+ "routePolicy", "viewerPresence", "displayName"); err != nil {
return nil, err
}
@@ -332,9 +330,6 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
if present.has("viewerPresence") && !message.ViewerPresence {
return nil, errors.New("viewerPresence must be true when present")
}
- if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
- return nil, errors.New("connectionAttemptProgress4 must be true when present")
- }
if !present.has("routePolicy") {
message.RoutePolicy = DefaultRoutePolicy
}
@@ -349,7 +344,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
return nil, err
}
if err := present.optional(
- "viewerGrant", "viewerPassword", "displayName", "viewerPresence", "connectionAttemptProgress4"); err != nil {
+ "viewerGrant", "viewerPassword", "displayName", "viewerPresence"); err != nil {
return nil, err
}
if message.Protocol != SignalingProtocol {
@@ -370,9 +365,6 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
if present.has("viewerPresence") && !message.ViewerPresence {
return nil, errors.New("viewerPresence must be true when present")
}
- if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
- return nil, errors.New("connectionAttemptProgress4 must be true when present")
- }
return message, nil
}
return nil, fmt.Errorf("unknown authenticate role %q", role)
diff --git a/internal/server/protocol/constants.go b/internal/server/protocol/constants.go
index d70fded9f..2414043f6 100644
--- a/internal/server/protocol/constants.go
+++ b/internal/server/protocol/constants.go
@@ -7,12 +7,18 @@ package protocol
// MaxSafeInteger is JavaScript's Number.MAX_SAFE_INTEGER.
const MaxSafeInteger = 9007199254740991
+// Opus format bounds, independent of UI presets and current sender capabilities.
+const (
+ MinAudioBitrate = 6_000
+ MaxAudioBitrate = 510_000
+)
+
// Limits and identifiers in the Piik wire contract.
const (
MaxViewersPerRoomLimit = 20
MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1
MaxSignalBytes = 64 * 1024
- SignalingProtocol = "piik-v23"
+ SignalingProtocol = "piik-v24"
RoomCodeLength = 4
MaxMediaRouteRevision = MaxSafeInteger
MaxIceServerURLs = 8
@@ -58,11 +64,11 @@ var NatTraversalPaths = [...]string{"unknown", "ordinary", "predicted"}
// DefaultQualitySettings mirrors DEFAULT_QUALITY_SETTINGS.
var DefaultQualitySettings = QualitySettings{
- Resolution: "1080p",
+ Resolution: QualityResolution{Width: 1920, Height: 1080},
MaxFramerate: 30,
MaxBitrate: 5_000_000,
DegradationPreference: "balanced",
- ScreenAudioQuality: "music",
+ MaxAudioBitrate: 128000,
}
// DefaultRoutePolicy mirrors DEFAULT_ROUTE_POLICY.
diff --git a/internal/server/protocol/contract_test.go b/internal/server/protocol/contract_test.go
index a3d74d33f..120492790 100644
--- a/internal/server/protocol/contract_test.go
+++ b/internal/server/protocol/contract_test.go
@@ -14,7 +14,6 @@ func TestRuntimeCapabilities(t *testing.T) {
want RuntimeCapabilities
}{
{`{}`, RuntimeCapabilities{}},
- {`{"connectionAttemptProgress4":true}`, RuntimeCapabilities{ConnectionAttemptProgress4: true}},
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
{`{"sfu":true,"sfuOnly":true}`, RuntimeCapabilities{Sfu: true, SfuOnly: true}},
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
@@ -29,7 +28,6 @@ func TestRuntimeCapabilities(t *testing.T) {
for _, data := range []string{
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`, `{"sfuOnly":null}`, `{"sfuOnly":"true"}`,
`{"natPrediction":null}`, `{"natPrediction":1}`,
- `{"connectionAttemptProgress4":null}`, `{"connectionAttemptProgress4":4}`,
} {
var got RuntimeCapabilities
if err := json.Unmarshal([]byte(data), &got); err == nil {
@@ -117,7 +115,7 @@ func drop(base []member, keys ...string) []member {
const (
testAssignmentNone = `{"upstream":{"kind":"none"},"childPeerIds":[],` +
`"sfuPublicationGeneration":null}`
- testQualitySettings = `{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,` +
+ testQualitySettings = `{"resolution":{"width":1920,"height":1080},"maxFramerate":60,"maxBitrate":8000000,` +
`"degradationPreference":"maintain-resolution"}`
testRoutePolicy = `{"peerOnly":false,"topologyOptimization":true,"natPrediction":false}`
testIceConfig = `{"iceServers":[],"natPredictionStunUrls":[]}`
@@ -126,7 +124,7 @@ const (
func authenticatedHost() []member {
return []member{
{"type", `"authenticated"`},
- {"protocol", `"piik-v23"`},
+ {"protocol", `"piik-v24"`},
{"peerId", `"host_12345678"`},
{"maxViewers", `8`},
{"endpointMediaCopyCapacity", `2`},
@@ -159,6 +157,32 @@ func assertServerMessage(t *testing.T, name string, data []byte, want bool) {
})
}
+func TestFutureReactionEventsKeepPayloadAndAuthorityBounds(t *testing.T) {
+ for _, role := range []string{"host", "viewer", "", "HOST", "admin"} {
+ assertServerMessage(t, "sender role "+role, []byte(
+ `{"type":"room-interaction","id":"event_future_1234","requestId":"request_future_1234","occurredAt":1,`+
+ `"sender":{"peerId":"sender_peer_1234","role":"`+role+`","displayName":"Friend"},"payload":{"kind":"reaction","reaction":"future-effect"}}`),
+ role == RoleHost || role == RoleViewer)
+ }
+ for _, payload := range []string{
+ `{"kind":"reaction","reaction":""}`,
+ `{"kind":"reaction","reaction":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`,
+ `{"kind":"reaction","reaction":"future effect"}`,
+ `{"kind":"reaction","reaction":"👋"}`,
+ `{"kind":"reaction","reaction":"future-effect","targetPeerId":"bad"}`,
+ `{"kind":"reaction","reaction":"future-effect","text":"hidden command"}`,
+ `{"kind":"reaction","reaction":"tomato"}`,
+ `{"kind":"future-kind","reaction":"wave"}`,
+ } {
+ raw := `{"type":"room-interaction","id":"event_future_1234","requestId":"request_future_1234","occurredAt":1,` +
+ `"sender":{"peerId":"sender_peer_1234","role":"viewer","displayName":"Friend"},"payload":` + payload + `}`
+ assertServerMessage(t, payload, []byte(raw), false)
+ }
+ assertServerMessage(t, "future reaction with invalid sender", []byte(
+ `{"type":"room-interaction","id":"event_future_1234","requestId":"request_future_1234","occurredAt":1,`+
+ `"sender":{"peerId":"bad","role":"viewer","displayName":"Friend"},"payload":{"kind":"reaction","reaction":"future-effect"}}`), false)
+}
+
func assertClientMessage(t *testing.T, name string, data []byte, want bool) {
t.Helper()
t.Run(name, func(t *testing.T) {
@@ -223,9 +247,9 @@ func TestAuthenticatedRoomRevision(t *testing.T) {
object(drop(authenticatedHost(), "routeAssignment")), false)
assertServerMessage(t, "absent media mode",
object(drop(authenticatedHost(), "mediaMode")), false)
- assertServerMessage(t, "quality settings past the bitrate ceiling",
+ assertServerMessage(t, "quality settings past the numeric representation",
object(with(authenticatedHost(), member{"qualitySettings",
- `{"resolution":"1080p","maxFramerate":60,"maxBitrate":20000000,` +
+ `{"resolution":{"width":1920,"height":1080},"maxFramerate":60,"maxBitrate":4294967296,` +
`"degradationPreference":"maintain-resolution"}`})), false)
}
@@ -479,7 +503,9 @@ func TestPreparedRouteCandidate(t *testing.T) {
{`{"current":1,"total":4}`, true},
{`{"current":4,"total":4}`, true},
{`{"current":5,"total":4}`, false},
- {`{"current":1,"total":5}`, false},
+ {`{"current":1,"total":5}`, true},
+ {`{"current":1,"total":0}`, false},
+ {`{"current":1,"total":9007199254740992}`, false},
{`{"current":1.5,"total":3}`, false},
} {
assertServerMessage(t, "connectionAttempt "+sample.attempt,
@@ -558,7 +584,7 @@ func TestDuplicateKeysKeepTheLastOccurrence(t *testing.T) {
}
// A duplicated object is replaced, not merged.
if _, err := DecodeClientMessage(
- []byte(`{"type":"set-quality-settings","qualitySettings":{"resolution":"480p"},` +
+ []byte(`{"type":"set-quality-settings","qualitySettings":{"resolution":{"width":854,"height":480}},` +
`"qualitySettings":` + testQualitySettings + `}`)); err != nil {
t.Fatalf("a duplicated object must be replaced wholesale: %v", err)
}
@@ -670,22 +696,9 @@ func TestOptionalAndNullableAreExact(t *testing.T) {
}
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
-func TestAuthenticateConnectionProgressOptIn(t *testing.T) {
- for _, role := range []string{"host", "viewer"} {
- base := `"type":"authenticate","protocol":"` + SignalingProtocol + `","roomId":"1234","clientId":"client_12345678","role":"` + role + `"`
- if role == "host" {
- base += `,"token":"` + repeat("a", 43) + `"`
- }
- for _, value := range []string{"true", "false", "null", "4", `"true"`} {
- assertClientMessage(t, role+value, []byte(`{`+base+`,"connectionAttemptProgress4":`+value+`}`), value == "true")
- }
- assertClientMessage(t, role+" baseline", []byte(`{`+base+`}`), true)
- }
-}
-
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
message, err := DecodeClientMessage([]byte(
- `{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
+ `{"type":"authenticate","protocol":"piik-v24","roomId":"1234","role":"host",` +
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`))
if err != nil {
t.Fatal(err)
@@ -701,7 +714,7 @@ func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
func TestAuthenticateAcceptsPersistentRoomSessionOptIn(t *testing.T) {
message, err := DecodeClientMessage([]byte(
- `{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
+ `{"type":"authenticate","protocol":"piik-v24","roomId":"1234","role":"host",` +
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678","roomSession":true,"roomOnly":true}`))
if err != nil {
t.Fatal(err)
@@ -711,7 +724,7 @@ func TestAuthenticateAcceptsPersistentRoomSessionOptIn(t *testing.T) {
t.Fatalf("decoded %#v, want room-session opt-in and room-only state", message)
}
if _, err := DecodeClientMessage([]byte(
- `{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
+ `{"type":"authenticate","protocol":"piik-v24","roomId":"1234","role":"host",` +
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678","roomOnly":true}`)); err == nil {
t.Fatal("accepted roomOnly without roomSession opt-in")
}
diff --git a/internal/server/protocol/interactions.go b/internal/server/protocol/interactions.go
index 51f987ffe..4ed2ad28e 100644
--- a/internal/server/protocol/interactions.go
+++ b/internal/server/protocol/interactions.go
@@ -2,6 +2,7 @@ package protocol
import (
"errors"
+ "regexp"
"strings"
"unicode/utf8"
@@ -11,6 +12,8 @@ import (
const MaxChatCodePoints = 280
const InteractionIntervalMs = 800
+var reactionIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+
type InteractionPayload struct {
Kind string `json:"kind"`
Text string `json:"text,omitempty"`
@@ -44,7 +47,8 @@ func (payload *InteractionPayload) UnmarshalJSON(data []byte) error {
if err = fields.optional("targetPeerId"); err != nil {
return err
}
- if fields.has("text") || (fields.has("targetPeerId") && !ValidOpaqueID(value.TargetPeerID)) {
+ if fields.has("text") || !reactionIDPattern.MatchString(value.Reaction) ||
+ (fields.has("targetPeerId") && !ValidOpaqueID(value.TargetPeerID)) {
return errors.New("invalid reaction payload")
}
switch value.Reaction {
@@ -52,9 +56,6 @@ func (payload *InteractionPayload) UnmarshalJSON(data []byte) error {
if !ValidOpaqueID(value.TargetPeerID) {
return errors.New("throwing a prop requires a participant")
}
- case "wave", "heart", "clap", "laugh", "wow", "party", "fire", "eyes", "star", "sleep":
- default:
- return errors.New("unknown reaction")
}
default:
return errors.New("unknown interaction kind")
@@ -93,6 +94,15 @@ func decodeSendRoomInteraction(data []byte) (ClientMessage, error) {
if !ValidOpaqueID(message.RequestID) {
return nil, errors.New("invalid interaction requestId")
}
+ // Only received presentation events are extensible. An unregistered command
+ // must never acquire meaning merely because its identifier is well-formed.
+ if message.Payload.Kind == "reaction" {
+ switch message.Payload.Reaction {
+ case "wave", "heart", "clap", "laugh", "wow", "party", "fire", "eyes", "star", "sleep", "tomato", "poop":
+ default:
+ return nil, errors.New("unknown reaction")
+ }
+ }
return message, nil
}
@@ -115,6 +125,9 @@ func (sender *InteractionSender) UnmarshalJSON(data []byte) error {
if !ValidOpaqueID(value.PeerID) {
return errors.New("invalid sender peerId")
}
+ if !enumOf(value.Role, RoleHost, RoleViewer) {
+ return errors.New("invalid sender role")
+ }
*sender = InteractionSender(value)
return nil
}
diff --git a/internal/server/protocol/punch.go b/internal/server/protocol/punch.go
new file mode 100644
index 000000000..e74c151de
--- /dev/null
+++ b/internal/server/protocol/punch.go
@@ -0,0 +1,65 @@
+package protocol
+
+import (
+ "encoding/hex"
+ "errors"
+ "net/netip"
+)
+
+type PunchPrediction struct {
+ Address string `json:"address"`
+ Port int `json:"port"`
+ Step int `json:"step"`
+}
+
+func (v *PunchPrediction) UnmarshalJSON(data []byte) error {
+ *v = PunchPrediction{}
+ type raw PunchPrediction
+ present, err := decodeObject(data, (*raw)(v))
+ if err != nil {
+ return err
+ }
+ if err = present.require("address", "port", "step"); err != nil {
+ return err
+ }
+ address, err := netip.ParseAddr(v.Address)
+ if err != nil || !address.Is4() || v.Port < 1024 || v.Port > 65535 || v.Step < 0 || v.Step > 16 || v.Port+4*v.Step > 65535 {
+ return errors.New("invalid mapping observation")
+ }
+ return nil
+}
+
+type PunchSignal struct {
+ Phase string `json:"phase"`
+ Secret string `json:"secret,omitempty"`
+ Prediction *PunchPrediction `json:"prediction,omitempty"`
+ Connected *bool `json:"connected,omitempty"`
+}
+
+func (v *PunchSignal) UnmarshalJSON(data []byte) error {
+ *v = PunchSignal{}
+ type raw PunchSignal
+ present, err := decodeObject(data, (*raw)(v))
+ if err != nil {
+ return err
+ }
+ if err = present.require("phase"); err != nil {
+ return err
+ }
+ valid := false
+ switch v.Phase {
+ case "prepare":
+ key, keyErr := hex.DecodeString(v.Secret)
+ valid = len(present) == 2 && keyErr == nil && len(key) == 32 && v.Secret == hex.EncodeToString(key)
+ case "ready":
+ valid = len(present) == 1 || (len(present) == 2 && v.Prediction != nil)
+ case "start":
+ valid = len(present) == 2 && v.Prediction != nil
+ case "complete":
+ valid = len(present) == 2 && v.Connected != nil
+ }
+ if !valid {
+ return errors.New("invalid connection preparation phase")
+ }
+ return nil
+}
diff --git a/internal/server/protocol/punch_test.go b/internal/server/protocol/punch_test.go
new file mode 100644
index 000000000..fd992b89d
--- /dev/null
+++ b/internal/server/protocol/punch_test.go
@@ -0,0 +1,49 @@
+package protocol
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestPunchSignalStrictBoundedRoundTrip(t *testing.T) {
+ prediction := `{"address":"198.51.100.1","port":40000,"step":1}`
+ for _, body := range []string{
+ `{"phase":"prepare","secret":"` + strings.Repeat("42", 32) + `"}`,
+ `{"phase":"ready"}`, `{"phase":"ready","prediction":` + prediction + `}`,
+ `{"phase":"start","prediction":` + prediction + `}`,
+ `{"phase":"start","prediction":{"address":"198.51.100.1","port":65535,"step":0}}`,
+ `{"phase":"complete","connected":false}`, `{"phase":"complete","connected":true}`,
+ } {
+ raw := `{"kind":"punch","connectionId":"connection_123456","punch":` + body + `}`
+ var payload SignalPayload
+ if err := json.Unmarshal([]byte(raw), &payload); err != nil {
+ t.Fatalf("%s: %v", body, err)
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil || string(encoded) != raw {
+ t.Fatalf("round trip %s: %s, %v", raw, encoded, err)
+ }
+ }
+ for _, body := range []string{
+ `{"phase":"prepare","secret":"bad"}`, `{"phase":"prepare","secret":null}`,
+ `{"phase":"ready","prediction":null}`, `{"phase":"ready","connected":true}`,
+ `{"phase":"start"}`, `{"phase":"complete"}`, `{"phase":"complete","connected":null}`,
+ `{"phase":"ready","prediction":` + prediction + `,"secret":"unused"}`,
+ `{"phase":"start","prediction":{"address":"::1","port":40000,"step":1}}`,
+ `{"phase":"start","prediction":{"address":"198.51.100.1","port":65535,"step":1}}`,
+ `{"phase":"start","prediction":{"address":"198.51.100.1","port":40000,"step":17}}`,
+ `{"phase":"start","prediction":{"address":"198.51.100.1","port":40000,"step":-1}}`,
+ } {
+ var payload PunchSignal
+ if err := json.Unmarshal([]byte(body), &payload); err == nil {
+ t.Fatalf("accepted %s", body)
+ }
+ }
+ // A decoder reused by a caller must not inherit a previous phase's fields.
+ var phase PunchSignal
+ _ = json.Unmarshal([]byte(`{"phase":"start","prediction":`+prediction+`}`), &phase)
+ if err := json.Unmarshal([]byte(`{"phase":"ready"}`), &phase); err != nil || phase.Prediction != nil {
+ t.Fatal("reused decoder retained a stale endpoint")
+ }
+}
diff --git a/internal/server/protocol/sfu.go b/internal/server/protocol/sfu.go
index 5e4000474..f7691a11e 100644
--- a/internal/server/protocol/sfu.go
+++ b/internal/server/protocol/sfu.go
@@ -27,12 +27,12 @@ func (value *SfuMedia) UnmarshalJSON(data []byte) error {
return err
}
if !enumOf(value.Codec, "h264", "vp8") || len(value.Layers) < 1 || len(value.Layers) > 3 ||
- !inRangeInt(value.AudioBitrate, 0, 510_000) || value.Audio && value.AudioBitrate == 0 {
+ !inRangeInt(value.AudioBitrate, 0, MaxAudioBitrate) || value.Audio && value.AudioBitrate == 0 {
return errors.New("invalid SFU media")
}
for index, layer := range value.Layers {
if len(layer.RID) > 16 || layer.RID == "" && len(value.Layers) != 1 ||
- !inRangeInt(layer.Width, 1, 8192) || !inRangeInt(layer.Height, 1, 8192) || !inRangeInt(layer.Bitrate, 1, 100_000_000) {
+ !inRangeInt(layer.Width, 1, 0xffff_ffff) || !inRangeInt(layer.Height, 1, 0xffff_ffff) || !inRangeInt(layer.Bitrate, 1, 0xffff_ffff) {
return errors.New("invalid SFU layer")
}
for _, previous := range value.Layers[:index] {
diff --git a/internal/server/protocol/types.go b/internal/server/protocol/types.go
index 4bfd18683..7efde368b 100644
--- a/internal/server/protocol/types.go
+++ b/internal/server/protocol/types.go
@@ -35,9 +35,7 @@ var (
// Enumerations mirrored from src/shared/protocol.ts.
var (
- qualityResolutions = []string{"480p", "720p", "1080p", "1440p"}
degradationPreferences = []string{"maintain-resolution", "balanced", "maintain-framerate"}
- screenAudioQualities = []string{"saver", "music", "very-high"}
mediaRoutePhases = []string{"prepare", "active"}
routeDemandReasons = []string{"join", "edge-unavailable", "parent-departed", "capacity-reduction", "sfu-bootstrap", "direct-convergence", "quality-convergence", "root-convergence"}
routeDiagnosticFinalRoutes = []string{"direct", "sfu", "waiting", "failed"}
@@ -106,13 +104,35 @@ func (v *MediaRouteUpstream) UnmarshalJSON(data []byte) error {
// qualitySettingsSchema / routePolicySchema / runtimeCapabilitiesSchema
// ---------------------------------------------------------------------------
+// QualityResolution is a requested ceiling, not encoder availability or an allocation.
+type QualityResolution struct {
+ Width Int `json:"width"`
+ Height Int `json:"height"`
+}
+
+func (v *QualityResolution) UnmarshalJSON(data []byte) error {
+ *v = QualityResolution{}
+ type raw QualityResolution
+ present, err := decodeObject(data, (*raw)(v))
+ if err != nil {
+ return err
+ }
+ if err := present.require("width", "height"); err != nil {
+ return err
+ }
+ if !inRangeInt(v.Width, 1, 0xffff_ffff) || !inRangeInt(v.Height, 1, 0xffff_ffff) {
+ return errors.New("resolution is out of range")
+ }
+ return nil
+}
+
// QualitySettings mirrors qualitySettingsSchema.
type QualitySettings struct {
- Resolution string `json:"resolution"`
- MaxFramerate Int `json:"maxFramerate"`
- MaxBitrate Int `json:"maxBitrate"`
- DegradationPreference string `json:"degradationPreference"`
- ScreenAudioQuality string `json:"screenAudioQuality,omitempty"`
+ Resolution QualityResolution `json:"resolution"`
+ MaxFramerate Num `json:"maxFramerate"`
+ MaxBitrate Int `json:"maxBitrate"`
+ DegradationPreference string `json:"degradationPreference"`
+ MaxAudioBitrate Int `json:"maxAudioBitrate,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
@@ -127,23 +147,20 @@ func (v *QualitySettings) UnmarshalJSON(data []byte) error {
"resolution", "maxFramerate", "maxBitrate", "degradationPreference"); err != nil {
return err
}
- if err := present.optional("screenAudioQuality"); err != nil {
+ if err := present.optional("maxAudioBitrate"); err != nil {
return err
}
- if !enumOf(v.Resolution, qualityResolutions...) {
- return fmt.Errorf("unknown resolution %q", v.Resolution)
- }
- if !inRangeInt(v.MaxFramerate, 15, 60) {
+ if v.MaxFramerate <= 0 || !inRangeNum(v.MaxFramerate, 0, MaxSafeInteger) {
return errors.New("maxFramerate is out of range")
}
- if !inRangeInt(v.MaxBitrate, 2_000_000, 12_000_000) {
+ if !inRangeInt(v.MaxBitrate, 1, 0xffff_ffff) {
return errors.New("maxBitrate is out of range")
}
if !enumOf(v.DegradationPreference, degradationPreferences...) {
return fmt.Errorf("unknown degradationPreference %q", v.DegradationPreference)
}
- if present.has("screenAudioQuality") && !enumOf(v.ScreenAudioQuality, screenAudioQualities...) {
- return fmt.Errorf("unknown screenAudioQuality %q", v.ScreenAudioQuality)
+ if present.has("maxAudioBitrate") && !inRangeInt(v.MaxAudioBitrate, MinAudioBitrate, MaxAudioBitrate) {
+ return errors.New("maxAudioBitrate is out of range")
}
return nil
}
@@ -168,12 +185,11 @@ func (v *RoutePolicy) UnmarshalJSON(data []byte) error {
// RuntimeCapabilities mirrors runtimeCapabilitiesSchema.
type RuntimeCapabilities struct {
- ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
- Sfu bool `json:"sfu"`
- SfuOnly bool `json:"sfuOnly,omitempty"`
- NatPrediction bool `json:"natPrediction"`
- RoomInteractions bool `json:"roomInteractions,omitempty"`
- HostRoomSession bool `json:"hostRoomSession,omitempty"`
+ Sfu bool `json:"sfu"`
+ SfuOnly bool `json:"sfuOnly,omitempty"`
+ NatPrediction bool `json:"natPrediction"`
+ RoomInteractions bool `json:"roomInteractions,omitempty"`
+ HostRoomSession bool `json:"hostRoomSession,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
@@ -186,14 +202,13 @@ func (v *RuntimeCapabilities) UnmarshalJSON(data []byte) error {
if present == nil {
return errors.New("runtime capabilities must be an object")
}
- if err := present.optional("sfu", "sfuOnly", "roomInteractions", "hostRoomSession", "natPrediction", "connectionAttemptProgress4"); err != nil {
+ if err := present.optional("sfu", "sfuOnly", "roomInteractions", "hostRoomSession", "natPrediction"); err != nil {
return err
}
for key, target := range map[string]*bool{
"sfu": &v.Sfu, "sfuOnly": &v.SfuOnly, "natPrediction": &v.NatPrediction,
- "connectionAttemptProgress4": &v.ConnectionAttemptProgress4,
- "roomInteractions": &v.RoomInteractions, "hostRoomSession": &v.HostRoomSession,
+ "roomInteractions": &v.RoomInteractions, "hostRoomSession": &v.HostRoomSession,
} {
if value, ok := present[key]; ok {
if err := json.Unmarshal(value, target); err != nil {
@@ -399,16 +414,26 @@ type SignalPayload struct {
ConnectionID string
Description *SessionDescription
Candidate *IceCandidate
+ Punch *PunchSignal
+ Punched bool
}
// MarshalJSON implements json.Marshaler.
func (v SignalPayload) MarshalJSON() ([]byte, error) {
+ if v.Kind == "punch" {
+ return marshalJSON(struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Punch *PunchSignal `json:"punch"`
+ }{v.Kind, v.ConnectionID, v.Punch})
+ }
if v.Kind == "description" {
return marshalJSON(struct {
Kind string `json:"kind"`
ConnectionID string `json:"connectionId"`
Description *SessionDescription `json:"description"`
- }{v.Kind, v.ConnectionID, v.Description})
+ Punched bool `json:"punched,omitempty"`
+ }{v.Kind, v.ConnectionID, v.Description, v.Punched})
}
return marshalJSON(struct {
Kind string `json:"kind"`
@@ -425,11 +450,30 @@ func (v *SignalPayload) UnmarshalJSON(data []byte) error {
return err
}
switch kind {
+ case "punch":
+ var body struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Punch *PunchSignal `json:"punch"`
+ }
+ present, err := decodeObject(data, &body)
+ if err != nil {
+ return err
+ }
+ if err = present.require("kind", "connectionId", "punch"); err != nil {
+ return err
+ }
+ if !ValidOpaqueID(body.ConnectionID) || body.Punch == nil {
+ return errors.New("invalid connection preparation signal")
+ }
+ *v = SignalPayload{Kind: kind, ConnectionID: body.ConnectionID, Punch: body.Punch}
+ return nil
case "description":
var body struct {
Kind string `json:"kind"`
ConnectionID string `json:"connectionId"`
Description *SessionDescription `json:"description"`
+ Punched bool `json:"punched,omitempty"`
}
present, err := decodeObject(data, &body)
if err != nil {
@@ -441,7 +485,10 @@ func (v *SignalPayload) UnmarshalJSON(data []byte) error {
if !ValidOpaqueID(body.ConnectionID) {
return errors.New("connectionId is not an opaque id")
}
- *v = SignalPayload{Kind: kind, ConnectionID: body.ConnectionID, Description: body.Description}
+ if _, exists := present["punched"]; exists && !body.Punched {
+ return errors.New("punched must be true when present")
+ }
+ *v = SignalPayload{Kind: kind, ConnectionID: body.ConnectionID, Description: body.Description, Punched: body.Punched}
return nil
case "candidate":
var body struct {
@@ -492,8 +539,8 @@ func (v *ConnectionAttempt) UnmarshalJSON(data []byte) error {
if !inRangeInt(v.Current, 1, int64(v.Total)) {
return errors.New("connectionAttempt.current is out of range")
}
- if v.Total != 3 && v.Total != 4 {
- return errors.New("connectionAttempt.total must be 3 or 4")
+ if !inRangeInt(v.Total, 1, MaxSafeInteger) {
+ return errors.New("connectionAttempt.total is out of range")
}
return nil
}
@@ -979,11 +1026,11 @@ func (v *ViewerQualityEvidenceMetrics) UnmarshalJSON(data []byte) error {
value *Int
minimum, maximum int64
}{
- {v.Width, 1, 16_384}, {v.Height, 1, 16_384},
- {v.PacketsReceivedDelta, 0, 1_000_000}, {v.PacketsLostDelta, 0, 1_000_000},
- {v.FramesDecodedDelta, 0, 10_000}, {v.FramesDroppedDelta, 0, 10_000},
+ {v.Width, 1, MaxSafeInteger}, {v.Height, 1, MaxSafeInteger},
+ {v.PacketsReceivedDelta, 0, MaxSafeInteger}, {v.PacketsLostDelta, 0, MaxSafeInteger},
+ {v.FramesDecodedDelta, 0, MaxSafeInteger}, {v.FramesDroppedDelta, 0, MaxSafeInteger},
{v.FreezeCountDelta, 0, MaxSafeInteger}, {v.PauseCountDelta, 0, MaxSafeInteger},
- {v.AudioConcealmentEventsDelta, 0, 10_000},
+ {v.AudioConcealmentEventsDelta, 0, MaxSafeInteger},
} {
if bound.value != nil && !inRangeInt(*bound.value, bound.minimum, bound.maximum) {
return errors.New("viewer quality evidence integer is out of range")
@@ -993,15 +1040,15 @@ func (v *ViewerQualityEvidenceMetrics) UnmarshalJSON(data []byte) error {
value *Num
minimum, maximum float64
}{
- {v.FramesPerSecond, 0, 240}, {v.BitrateKbps, 0, 100_000},
- {v.RttMs, 0, 60_000}, {v.JitterMs, 0, 60_000},
- {v.DecodeMsPerFrame, 0, 60_000},
+ {v.FramesPerSecond, 0, MaxSafeInteger}, {v.BitrateKbps, 0, MaxSafeInteger},
+ {v.RttMs, 0, MaxSafeInteger}, {v.JitterMs, 0, MaxSafeInteger},
+ {v.DecodeMsPerFrame, 0, MaxSafeInteger},
{v.FreezeDurationMsDelta, 0, MaxSafeInteger},
{v.PauseDurationMsDelta, 0, MaxSafeInteger},
- {v.AudioBitrateKbps, 0, 10_000}, {v.AudioPacketLossPercent, 0, 100},
- {v.AudioJitterMs, 0, 60_000},
- {v.AudioVideoPlayoutDeltaMs, -60_000, 60_000},
- {v.VideoJitterBufferDelayMs, 0, 60_000}, {v.AudioJitterBufferDelayMs, 0, 60_000},
+ {v.AudioBitrateKbps, 0, MaxSafeInteger}, {v.AudioPacketLossPercent, 0, 100},
+ {v.AudioJitterMs, 0, MaxSafeInteger},
+ {v.AudioVideoPlayoutDeltaMs, -MaxSafeInteger, MaxSafeInteger},
+ {v.VideoJitterBufferDelayMs, 0, MaxSafeInteger}, {v.AudioJitterBufferDelayMs, 0, MaxSafeInteger},
{v.AudioConcealedSamplesPercent, 0, 100},
} {
if bound.value != nil && !inRangeNum(*bound.value, bound.minimum, bound.maximum) {
@@ -1085,11 +1132,11 @@ func (v *SenderQualityDiagnostics) UnmarshalJSON(data []byte) error {
value *Num
minimum, maximum float64
}{
- {v.FramesPerSecond, 0, 240}, {v.BitrateKbps, 0, 100_000},
- {v.CaptureFramesPerSecond.Value, 0, 240},
- {v.MediaSourceFramesPerSecond.Value, 0, 240},
- {v.AvailableOutgoingKbps.Value, 0, 100_000},
- {v.RttMs.Value, 0, 60_000}, {v.PacketLossPercent.Value, 0, 100},
+ {v.FramesPerSecond, 0, MaxSafeInteger}, {v.BitrateKbps, 0, MaxSafeInteger},
+ {v.CaptureFramesPerSecond.Value, 0, MaxSafeInteger},
+ {v.MediaSourceFramesPerSecond.Value, 0, MaxSafeInteger},
+ {v.AvailableOutgoingKbps.Value, 0, MaxSafeInteger},
+ {v.RttMs.Value, 0, MaxSafeInteger}, {v.PacketLossPercent.Value, 0, 100},
} {
if bound.value != nil && !inRangeNum(*bound.value, bound.minimum, bound.maximum) {
return errors.New("sender quality diagnostic number is out of range")
@@ -1099,7 +1146,7 @@ func (v *SenderQualityDiagnostics) UnmarshalJSON(data []byte) error {
value *Int
maximum int64
}{
- {v.Width.Value, 16_384}, {v.Height.Value, 16_384},
+ {v.Width.Value, MaxSafeInteger}, {v.Height.Value, MaxSafeInteger},
{v.VideoEncodingCount.Value, MaxSafeInteger},
{v.ActiveVideoEncodingCount.Value, MaxSafeInteger},
} {
diff --git a/internal/server/room/database.go b/internal/server/room/database.go
index 94657c0f7..e57c04c15 100644
--- a/internal/server/room/database.go
+++ b/internal/server/room/database.go
@@ -89,9 +89,6 @@ type Database struct {
path string
db *sql.DB
conn *sql.Conn
- // inTransaction reproduces DatabaseSync.isTransaction, which the driver
- // does not expose; the ROLLBACK guard depends on it.
- inTransaction bool
}
// NewDatabase is the RoomDatabase constructor.
@@ -141,16 +138,16 @@ func (d *Database) Initialize() (restored []StoredRoomAuthority, err error) {
if lockingMode != "exclusive" {
return nil, errors.New("Room database could not acquire exclusive locking mode")
}
- if err = d.begin("BEGIN EXCLUSIVE"); err != nil {
+ if _, err = d.exec("BEGIN EXCLUSIVE"); err != nil {
return nil, err
}
restored, err = d.recover()
if err != nil {
- d.rollbackIfNeeded()
+ d.rollback()
return nil, err
}
- if err = d.finish("COMMIT"); err != nil {
- d.rollbackIfNeeded()
+ if _, err = d.exec("COMMIT"); err != nil {
+ d.rollback()
return nil, err
}
return restored, nil
@@ -173,7 +170,7 @@ func (d *Database) recover() ([]StoredRoomAuthority, error) {
return d.readStoredRooms()
}
-// InsertRoom is insertRoom.
+// InsertRoom persists one validated authority record atomically.
func (d *Database) InsertRoom(room StoredRoomAuthority) error {
if err := assertStoredRoom(room); err != nil {
return err
@@ -193,7 +190,7 @@ func (d *Database) InsertRoom(room StoredRoomAuthority) error {
})
}
-// SetViewerPassword is setViewerPassword; nil material clears the password.
+// SetViewerPassword updates the exact Host's room; nil material clears the password.
func (d *Database) SetViewerPassword(
roomID string, hostTokenDigest, viewerPasswordMaterial []byte,
) error {
@@ -216,7 +213,7 @@ func (d *Database) SetViewerPassword(
})
}
-// SetCodeEntryPolicy is setCodeEntryPolicy.
+// SetCodeEntryPolicy persists code admission for the exact Host's room.
func (d *Database) SetCodeEntryPolicy(
roomID string, hostTokenDigest []byte, codeEntryPolicy protocol.CodeEntryPolicy,
) error {
@@ -239,7 +236,7 @@ func (d *Database) SetCodeEntryPolicy(
})
}
-// SetViewerGrant is setViewerGrant.
+// SetViewerGrant atomically replaces the grant digest and authorization generation.
func (d *Database) SetViewerGrant(
roomID string, hostTokenDigest, viewerGrantDigest []byte,
viewerAuthorizationGeneration string,
@@ -330,7 +327,7 @@ func (d *Database) Close() error {
func (d *Database) closeHandles() error {
conn, db := d.conn, d.db
- d.conn, d.db, d.inTransaction = nil, nil, false
+ d.conn, d.db = nil, nil
var connError error
if conn != nil {
connError = conn.Close()
@@ -531,42 +528,25 @@ func (d *Database) transaction(operation func() error) error {
if d.conn == nil {
return errors.New("Room database is not initialized")
}
- if err := d.begin("BEGIN IMMEDIATE"); err != nil {
+ if _, err := d.exec("BEGIN IMMEDIATE"); err != nil {
return err
}
if err := operation(); err != nil {
- d.rollbackIfNeeded()
+ d.rollback()
return err
}
- if err := d.finish("COMMIT"); err != nil {
- d.rollbackIfNeeded()
+ if _, err := d.exec("COMMIT"); err != nil {
+ d.rollback()
return err
}
return nil
}
-func (d *Database) begin(statement string) error {
- if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
- return err
- }
- d.inTransaction = true
- return nil
-}
-
-func (d *Database) finish(statement string) error {
- if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
- return err
- }
- d.inTransaction = false
- return nil
-}
-
-// rollbackIfNeeded is `if (database.isTransaction) database.exec("ROLLBACK")`:
-// a ROLLBACK with no active transaction is itself an error.
-func (d *Database) rollbackIfNeeded() {
- if d.inTransaction {
- _ = d.finish("ROLLBACK")
- }
+// SQLite may already have rolled back after an I/O or capacity error. An
+// explicit rollback is harmless then; SQLite, not a Go mirror, owns that state.
+// Preserve the original failure rather than replacing it with "no transaction".
+func (d *Database) rollback() {
+ _, _ = d.exec("ROLLBACK")
}
func (d *Database) exec(query string, args ...any) (int64, error) {
diff --git a/internal/server/room/database_test.go b/internal/server/room/database_test.go
index ac33dceab..3ed405fc1 100644
--- a/internal/server/room/database_test.go
+++ b/internal/server/room/database_test.go
@@ -4,12 +4,17 @@ import (
"bytes"
"crypto/sha256"
"database/sql"
+ "errors"
+ "fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
+ "modernc.org/sqlite"
+ sqlite3 "modernc.org/sqlite/lib"
+
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
@@ -91,6 +96,121 @@ func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
}
}
+func TestRoomCreationStorageFailuresPreserveAuthority(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ install string
+ remove string
+ code int
+ }{
+ {
+ name: "read-only connection", install: "PRAGMA query_only = ON",
+ remove: "PRAGMA query_only = OFF", code: sqlite3.SQLITE_READONLY,
+ },
+ {
+ // The room INSERT succeeds; a deferred constraint rejects COMMIT.
+ // Temporary objects leave the production schema unchanged.
+ name: "commit rejection",
+ install: `PRAGMA foreign_keys = ON;
+ CREATE TEMP TABLE fault_parent (id INTEGER PRIMARY KEY);
+ CREATE TEMP TABLE fault_child (
+ id INTEGER REFERENCES fault_parent(id) DEFERRABLE INITIALLY DEFERRED
+ );
+ CREATE TEMP TRIGGER reject_commit AFTER INSERT ON main.rooms
+ BEGIN INSERT INTO fault_child VALUES (1); END;`,
+ remove: "DROP TRIGGER temp.reject_commit",
+ code: sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ path := databasePath(t)
+ store := stableStore(t, path, Options{})
+ createRoom(t, store, protocol.CodeEntryOpen, "", "4321")
+ before, err := store.database.readStoredRooms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ free := len(store.freeRoomCodes)
+ if _, err := store.database.exec(testCase.install); err != nil {
+ t.Fatal(err)
+ }
+ _, err = tryCreateRoom(store, protocol.CodeEntryOpen, "", "5678")
+ var sqliteErr *sqlite.Error
+ if !errors.As(err, &sqliteErr) || sqliteErr.Code() != testCase.code {
+ t.Fatalf("create error = %v, want SQLite code %d", err, testCase.code)
+ }
+ after, err := store.database.readStoredRooms()
+ if err != nil || !reflect.DeepEqual(before, after) || store.Size() != 1 || len(store.freeRoomCodes) != free {
+ t.Fatalf("failed write changed authority: rooms=%d, free=%d, error=%v", store.Size(), len(store.freeRoomCodes), err)
+ }
+ if _, err := store.database.exec(testCase.remove); err != nil {
+ t.Fatal(err)
+ }
+ created := createRoom(t, store, protocol.CodeEntryOpen, "", "5678")
+ if created.RoomID != "5678" {
+ t.Fatalf("failed write did not release room code: %s", created.RoomID)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ restored := stableStore(t, path, Options{})
+ if restored.Size() != 2 {
+ t.Fatalf("restored rooms = %d, want 2", restored.Size())
+ }
+ if _, err := restored.HostManagedRoom(created.RoomID, created.HostToken); err != nil {
+ t.Fatalf("restored Host authority: %v", err)
+ }
+ })
+ }
+}
+
+func TestDatabaseFullDoesNotConsumeRoomAuthorityOrPreventLaterWrites(t *testing.T) {
+ path := databasePath(t)
+ store := stableStore(t, path, Options{MaxRooms: 1000})
+ initialFree := len(store.freeRoomCodes)
+ pages, err := store.database.readPragmaInteger("page_count")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.database.exec(fmt.Sprintf("PRAGMA max_page_count = %d", pages)); err != nil {
+ t.Fatal(err)
+ }
+ for n := 0; n < 1000; n++ {
+ roomID := fmt.Sprintf("%04d", 1000+n)
+ _, err := tryCreateRoom(store, protocol.CodeEntryOpen, "", roomID)
+ if err == nil {
+ continue
+ }
+ var sqliteErr *sqlite.Error
+ if !errors.As(err, &sqliteErr) || sqliteErr.Code() != sqlite3.SQLITE_FULL {
+ t.Fatalf("create error = %v, want SQLITE_FULL", err)
+ }
+ rows, err := store.database.readStoredRooms()
+ if err != nil || len(rows) != n || store.Size() != n || len(store.freeRoomCodes) != initialFree-n {
+ t.Fatalf("full database changed authority: rows=%d, rooms=%d, free=%d, error=%v", len(rows), store.Size(), len(store.freeRoomCodes), err)
+ }
+ if _, err := store.database.exec("PRAGMA max_page_count = 1000"); err != nil {
+ t.Fatal(err)
+ }
+ created := createRoom(t, store, protocol.CodeEntryOpen, "", roomID)
+ if created.RoomID != roomID {
+ t.Fatalf("recovered room code = %s, want %s", created.RoomID, roomID)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ restored := stableStore(t, path, Options{MaxRooms: 1000})
+ if restored.Size() != n+1 {
+ t.Fatalf("restored rooms = %d, want %d", restored.Size(), n+1)
+ }
+ if _, err := restored.HostManagedRoom(created.RoomID, created.HostToken); err != nil {
+ t.Fatalf("restored Host authority: %v", err)
+ }
+ return
+ }
+ t.Fatal("bounded database did not reach SQLITE_FULL")
+}
+
func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
path := databasePath(t)
first := stableStore(t, path, Options{})
diff --git a/internal/server/room/password.go b/internal/server/room/password.go
index 17841d264..5776a8d95 100644
--- a/internal/server/room/password.go
+++ b/internal/server/room/password.go
@@ -26,25 +26,21 @@ const (
gatePendingLimit = 16
)
-// errDerivationCancelled is the AsyncGate "cancelled" outcome. Callers never
-// inspect it: they hand it to the commit method, which maps it the way the
-// TypeScript call site did.
+// errDerivationCancelled means the queued caller lost authority before its
+// derivation started. The caller maps it to the operation's error.
var errDerivationCancelled = errors.New("password derivation was cancelled")
-// isGateOutcome reports whether err is one of the two AsyncGate outcomes rather
-// than a thrown error. The TypeScript returned busy and cancelled as values,
-// which its post-await checks inspected after re-reading the room; everything
-// else threw out of the await.
+// isGateOutcome identifies admission results that the commit boundary resolves
+// after rechecking room authority. Other derivation errors return immediately.
func isGateOutcome(err error) bool {
return errors.Is(err, errDerivationCancelled) || hasCode(err, CodeRoomBusy)
}
-// DeriveViewerPasswordMaterial is createViewerPasswordMaterial: it validates the
-// password, draws a fresh salt and returns salt||verifier. Call it WITHOUT the
-// caller's lock held and hand both results to CreateRoom, ReplaceRoom or
-// SetViewerPassword unchanged; those reproduce the TypeScript order in which a
-// busy or cancelled outcome is turned into an error. mayStart may be nil, which
-// is the TypeScript default of `() => true`.
+// DeriveViewerPasswordMaterial validates the password, draws a fresh salt and
+// returns salt||verifier. Call it without the caller's lock held and pass both
+// results to CreateRoom, ReplaceRoom or SetViewerPassword unchanged so those
+// commits can recheck authority before reporting busy/cancelled admission.
+// A nil mayStart permits derivation once the gate admits it.
func (s *Store) DeriveViewerPasswordMaterial(
password string, mayStart func() bool,
) ([]byte, error) {
@@ -62,10 +58,9 @@ func (s *Store) DeriveViewerPasswordMaterial(
return slices.Concat(salt, verifier), nil
}
-// DeriveViewerPassword is the unlocked middle of connectViewerWithPassword: it
-// derives the verifier for the salt ViewerPasswordChallenge returned. Unlike
-// DeriveViewerPasswordMaterial it maps both gate outcomes itself, because the
-// TypeScript threw them immediately after the await, before re-reading the room.
+// DeriveViewerPassword is the unlocked password-admission step: it derives the
+// verifier for the salt ViewerPasswordChallenge returned. Gate failures return
+// before the room commit; cancelled admission is reported as an invalid token.
func (s *Store) DeriveViewerPassword(
password string, salt []byte, mayConnect func() bool,
) ([]byte, error) {
@@ -89,7 +84,7 @@ func (s *Store) deriveViewerPassword(
})
}
-// gate is AsyncGate: at most limit derivations run at once and at most
+// gate admits at most limit concurrent derivations and at most
// pendingLimit wait, first in first out. A saturated gate answers ROOM_BUSY.
type gate struct {
mu sync.Mutex
@@ -128,8 +123,8 @@ func (g *gate) acquire() bool {
return true
}
-// release hands the slot to the first waiter instead of decrementing, which is
-// what `const next = this.waiters.shift(); if (next) { next(); return; }` did.
+// release transfers the active slot to the first waiter, keeping new callers
+// behind already queued derivations.
func (g *gate) release() {
g.mu.Lock()
if len(g.waiters) > 0 {
diff --git a/internal/server/room/store.go b/internal/server/room/store.go
index 34f79a73c..6c4dd5f30 100644
--- a/internal/server/room/store.go
+++ b/internal/server/room/store.go
@@ -18,15 +18,15 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
-// Capacity is ROOM_CAPACITY: every four-digit room code.
+// Capacity is the number of four-digit room codes without a leading zero.
const Capacity = 9_000
const roomCodeFirst = 1_000
-// ErrorCode is RoomStoreErrorCode.
+// ErrorCode identifies a room authority or admission failure.
type ErrorCode string
-// The RoomStoreErrorCode set.
+// Room authority and admission errors.
const (
CodeInvalidToken ErrorCode = "INVALID_TOKEN"
CodeRoomAccessDenied ErrorCode = "ROOM_ACCESS_DENIED"
@@ -37,12 +37,12 @@ const (
CodeRoomLimit ErrorCode = "ROOM_LIMIT"
)
-// Error is RoomStoreError. Its message is the code, as `super(code)` made it.
+// Error carries a room authority or admission error code.
type Error struct {
Code ErrorCode
}
-// Error returns the code, which is the message `super(code)` produced.
+// Error returns the code.
func (e *Error) Error() string { return string(e.Code) }
func codeError(code ErrorCode) error { return &Error{Code: code} }
@@ -61,15 +61,14 @@ const (
type participant struct {
clientID string
peerID string
- // sessionID is "" where the TypeScript left sessionId undefined. Session
- // IDs are opaque IDs of at least 8 characters, so "" cannot collide.
+ // An empty sessionID means offline; validated wire IDs are never empty.
sessionID string
admittedBy string
}
// Room is one room's authority plus its participants. It has no exported
// members: callers hold the pointer only to compare identity across the
-// unlocked password derivation, as the TypeScript compared room objects.
+// unlocked password derivation.
type Room struct {
roomID string
hostTokenDigest []byte
@@ -81,12 +80,12 @@ type Room struct {
// Process-local: restored rooms receive a fresh reconnect window. Only the
// last authenticated departure updates this; failed admission cannot renew it.
emptySinceMs int64
- // ordering: JS Map insertion order decides viewer presence order, the
+ // Insertion order decides viewer presence order, the
// closed-session list and the router's participant order.
viewers ordered.Map[string, *participant]
}
-// CreatedRoom is CreatedRoom. ViewerGrant is "" where the TypeScript had null.
+// CreatedRoom carries newly issued Host authority and an optional Viewer grant.
type CreatedRoom struct {
RoomID string
HostToken string
@@ -94,8 +93,8 @@ type CreatedRoom struct {
ViewerGrant string
}
-// ConnectParticipantInput is ConnectParticipantInput; Token is read for a host
-// and ViewerGrant for a viewer, "" meaning the key was absent.
+// ConnectParticipantInput uses Token for a Host and ViewerGrant for a Viewer.
+// Empty credentials are absent.
type ConnectParticipantInput struct {
RoomID string
Role protocol.Role
@@ -105,7 +104,7 @@ type ConnectParticipantInput struct {
SessionID string
}
-// ConnectViewerWithPasswordInput is ConnectViewerWithPasswordInput. Password is
+// ConnectViewerWithPasswordInput supplies Viewer password admission. Password is
// consumed by the caller's DeriveViewerPassword call, not by the commit.
type ConnectViewerWithPasswordInput struct {
RoomID string
@@ -114,8 +113,8 @@ type ConnectViewerWithPasswordInput struct {
SessionID string
}
-// ConnectedParticipant is ConnectedParticipant. ReplacedSessionID is "" where
-// the TypeScript omitted replacedSessionId.
+// ConnectedParticipant records admission and any session it superseded.
+// ReplacedSessionID is empty when no connected session was replaced.
type ConnectedParticipant struct {
RoomID string
Role protocol.Role
@@ -127,39 +126,35 @@ type ConnectedParticipant struct {
ViewerAuthorizationGeneration string
}
-// DisconnectedParticipant is DisconnectedParticipant.
type DisconnectedParticipant struct {
RoomID string
Role protocol.Role
PeerID string
}
-// ConnectedPeer is ConnectedPeer.
type ConnectedPeer struct {
PeerID string
SessionID string
}
-// ClosedRoom is ClosedRoom.
type ClosedRoom struct {
RoomID string
SessionIDs []string
}
-// ReplacedRoom is ReplacedRoom.
type ReplacedRoom struct {
Created CreatedRoom
Closed ClosedRoom
}
-// RevokedViewer is RevokedViewer; SessionID is "" where the TypeScript omitted
-// sessionId.
+// RevokedViewer identifies revoked admission. SessionID is empty for an offline Viewer.
type RevokedViewer struct {
PeerID string
SessionID string
}
-// ViewerGrantUpdate is ViewerGrantUpdate. ViewerGrant is "" for a revoke.
+// ViewerGrantUpdate carries a grant change and affected Viewers; ViewerGrant is
+// empty when the invitation was revoked.
type ViewerGrantUpdate struct {
ViewerGrant string
ViewerAuthorizationGeneration string
@@ -167,13 +162,12 @@ type ViewerGrantUpdate struct {
RevokedViewers []RevokedViewer
}
-// CodeEntryUpdate is CodeEntryUpdate.
type CodeEntryUpdate struct {
CodeEntryPolicy protocol.CodeEntryPolicy
ViewerPasswordEnabled bool
}
-// Options is RoomStoreOptions.
+// Options configures capacity, optional persistence and injectable entropy/time.
type Options struct {
MaxRooms int
MaxViewersPerRoom int
@@ -182,9 +176,9 @@ type Options struct {
Now func() int64
}
-// Store is RoomStore. See the package comment for the locking contract.
+// Store owns room authority. See the package comment for the locking contract.
type Store struct {
- // ordering: JS Map insertion order decides AbandonAllRooms.
+ // Insertion order determines the order returned by AbandonAllRooms.
rooms ordered.Map[string, *Room]
freeRoomCodes []string
random func(size int) []byte
@@ -193,13 +187,11 @@ type Store struct {
initialized bool
maxRooms int
maxViewersPerRoom int
- // gate is per store; the TypeScript module-level gate was shared by every
- // RoomStore in a process, of which there was one.
+ // The derivation gate bounds work across every room in this store.
gate gate
}
-// New is the RoomStore constructor; the TypeScript threw where this returns an
-// error.
+// New validates limits and prepares a Store; Initialize loads persisted authority.
func New(options Options) (*Store, error) {
if options.MaxRooms <= 0 || options.MaxRooms > Capacity {
return nil, fmt.Errorf("Room limit must be an integer between 1 and %d", Capacity)
@@ -318,7 +310,7 @@ func (s *Store) BeginCreateRoom() error {
return nil
}
-// CreateRoom is the commit half of createRoom. material and derived are the
+// CreateRoom commits room creation. material and derived are the
// results of DeriveViewerPasswordMaterial (both zero when no password was
// given).
func (s *Store) CreateRoom(
@@ -327,7 +319,7 @@ func (s *Store) CreateRoom(
derived error,
preferredRoomID string,
) (CreatedRoom, error) {
- // createViewerPasswordMaterial threw before any of the checks below.
+ // Derivation failures precede admission checks; gate outcomes are resolved below.
if derived != nil && !isGateOutcome(derived) {
return CreatedRoom{}, derived
}
@@ -359,8 +351,8 @@ func (s *Store) CreateRoom(
return created, nil
}
-// HostManagedRoom is getHostManagedRoom: the room the exact Host token owns.
-// It is the pre-KDF half of replaceRoom and setViewerPassword.
+// HostManagedRoom returns the room owned by the exact Host token. The caller
+// retains its identity while deriving password material outside the lock.
func (s *Store) HostManagedRoom(roomID, hostToken string) (*Room, error) {
if err := s.ensureInitialized(); err != nil {
return nil, err
@@ -368,7 +360,7 @@ func (s *Store) HostManagedRoom(roomID, hostToken string) (*Room, error) {
return s.getHostManagedRoom(roomID, hostToken)
}
-// HostStillOwnsRoom is the mayStart closure replaceRoom and setViewerPassword
+// HostStillOwnsRoom is the mayStart closure ReplaceRoom and SetViewerPassword
// hand to the KDF gate: the room must still be the same object and the token
// must still own it. Call it with the lock held.
func (s *Store) HostStillOwnsRoom(roomID, hostToken string, current *Room) bool {
@@ -376,7 +368,7 @@ func (s *Store) HostStillOwnsRoom(roomID, hostToken string, current *Room) bool
return ok && room == current && verifyDigest(hostToken, room.hostTokenDigest)
}
-// ReplaceRoom is the commit half of replaceRoom. current is the room
+// ReplaceRoom commits a replacement. current is the room
// HostManagedRoom returned before the derivation.
func (s *Store) ReplaceRoom(
roomID, hostToken string,
@@ -385,7 +377,7 @@ func (s *Store) ReplaceRoom(
derived error,
current *Room,
) (ReplacedRoom, error) {
- // createViewerPasswordMaterial threw before the second getHostManagedRoom.
+ // Derivation failures return before rechecking authority; gate outcomes do not.
if derived != nil && !isGateOutcome(derived) {
return ReplacedRoom{}, derived
}
@@ -438,7 +430,7 @@ func (s *Store) replaceRoom(
return ReplacedRoom{Created: created, Closed: closed}, nil
}
-// SetViewerPassword is the commit half of setViewerPassword; it reports whether
+// SetViewerPassword commits a password change; it reports whether
// a password is now set. current is the room HostManagedRoom returned before
// the derivation.
func (s *Store) SetViewerPassword(
@@ -447,7 +439,7 @@ func (s *Store) SetViewerPassword(
derived error,
current *Room,
) (bool, error) {
- // createViewerPasswordMaterial threw before the second getHostManagedRoom.
+ // Derivation failures return before rechecking authority; gate outcomes do not.
if derived != nil && !isGateOutcome(derived) {
return false, derived
}
@@ -474,7 +466,7 @@ func (s *Store) SetViewerPassword(
return material != nil, nil
}
-// SetCodeEntryPolicy is setCodeEntryPolicy.
+// SetCodeEntryPolicy updates code admission under exact Host authority.
func (s *Store) SetCodeEntryPolicy(
roomID string,
policy protocol.CodeEntryPolicy,
@@ -500,7 +492,7 @@ func (s *Store) SetCodeEntryPolicy(
}, nil
}
-// SetViewerGrant is setViewerGrant; action is "rotate" or "revoke".
+// SetViewerGrant rotates or revokes invitations and removes grant-admitted Viewers.
func (s *Store) SetViewerGrant(
roomID, action, hostToken string,
) (ViewerGrantUpdate, error) {
@@ -550,7 +542,7 @@ func (s *Store) SetViewerGrant(
room.viewerGrantDigest = viewerGrantDigest
room.viewerAuthorizationGeneration = generation
wasOccupied := roomOccupied(room)
- // ordering: delete during iteration, which a JS Map allows.
+ // ordered.Map.All permits removal without skipping surviving entries.
for clientID, viewer := range room.viewers.All() {
if viewer.admittedBy == admittedByGrant {
room.viewers.Delete(clientID)
@@ -567,7 +559,7 @@ func (s *Store) SetViewerGrant(
}, nil
}
-// ConnectParticipant is connectParticipant.
+// ConnectParticipant authenticates a Host token, Viewer grant or open code entry.
func (s *Store) ConnectParticipant(input ConnectParticipantInput) (ConnectedParticipant, error) {
if err := s.ensureInitialized(); err != nil {
return ConnectedParticipant{}, err
@@ -599,7 +591,7 @@ func (s *Store) ConnectParticipant(input ConnectParticipantInput) (ConnectedPart
return s.connectViewer(room, input.ClientID, input.SessionID, admittedByCode)
}
-// ViewerGrantMayEnter is viewerGrantMayEnter.
+// ViewerGrantMayEnter validates an invitation without admitting a participant.
func (s *Store) ViewerGrantMayEnter(roomID, grant string) (bool, error) {
if err := s.ensureInitialized(); err != nil {
return false, err
@@ -615,12 +607,12 @@ func (s *Store) ViewerGrantMayEnter(roomID, grant string) (bool, error) {
return viewerGrantIsValid(room, grant), nil
}
-// ViewerPasswordChallenge is the locked first half of connectViewerWithPassword:
+// ViewerPasswordChallenge is the locked first half of password admission:
// it returns the salt to derive against, a copy of the material to compare with
// and the room reference to re-check afterwards. A missing room and a room
// without a private password both answer with the same 48 zero
-// bytes so the derivation cost does not reveal which. The only error is the
-// `ensureInitialized()` the TypeScript ran before the password check.
+// bytes so the derivation cost does not reveal which. An uninitialized store
+// fails before this check.
func (s *Store) ViewerPasswordChallenge(
roomID string,
) (salt, expectedMaterial []byte, room *Room, err error) {
@@ -639,7 +631,7 @@ func (s *Store) ViewerPasswordChallenge(
return expectedMaterial[:viewerPasswordSaltBytes], expectedMaterial, found, nil
}
-// ConnectViewerWithPassword is the commit half of connectViewerWithPassword.
+// ConnectViewerWithPassword commits password admission after derivation.
// derived is the DeriveViewerPassword result; expectedMaterial and room come
// from ViewerPasswordChallenge; mayConnect is evaluated with the lock held.
func (s *Store) ConnectViewerWithPassword(
@@ -678,8 +670,8 @@ func (s *Store) ConnectViewerWithPassword(
return connected, err
}
-// DisconnectParticipant is disconnectParticipant; it returns nil where the
-// TypeScript returned undefined.
+// DisconnectParticipant clears only the matching session and returns nil when
+// the room, peer or current session no longer matches.
func (s *Store) DisconnectParticipant(
roomID, peerID, sessionID string,
) (*DisconnectedParticipant, error) {
@@ -715,7 +707,7 @@ func (s *Store) DisconnectParticipant(
RoomID: roomID, Role: protocol.RoleViewer, PeerID: peerID}, nil
}
-// RemoveDisconnectedViewer is removeDisconnectedViewer.
+// RemoveDisconnectedViewer removes a Viewer only if it has no current session.
func (s *Store) RemoveDisconnectedViewer(roomID, peerID string) bool {
room, ok := s.rooms.Get(roomID)
if !ok {
@@ -728,7 +720,7 @@ func (s *Store) RemoveDisconnectedViewer(roomID, peerID string) bool {
return room.viewers.Delete(viewer.clientID)
}
-// GetConnectedHost is getConnectedHost.
+// GetConnectedHost returns only a Host with a current session.
func (s *Store) GetConnectedHost(roomID string) (ConnectedPeer, bool) {
room, ok := s.rooms.Get(roomID)
if !ok || room.host == nil || room.host.sessionID == "" {
@@ -737,7 +729,7 @@ func (s *Store) GetConnectedHost(roomID string) (ConnectedPeer, bool) {
return ConnectedPeer{PeerID: room.host.peerID, SessionID: room.host.sessionID}, true
}
-// GetConnectedViewer is getConnectedViewer.
+// GetConnectedViewer returns only a Viewer with a current session.
func (s *Store) GetConnectedViewer(roomID, peerID string) (ConnectedPeer, bool) {
room, ok := s.rooms.Get(roomID)
if !ok {
@@ -779,8 +771,7 @@ func (s *Store) GetConnectedViewers(roomID string) []ConnectedPeer {
return connected
}
-// AbandonRoom is abandonRoom; it returns nil where the TypeScript returned
-// undefined.
+// AbandonRoom retires room authority, returning nil if the room is already absent.
func (s *Store) AbandonRoom(roomID string) (*ClosedRoom, error) {
closed, err := s.abandonRooms([]string{roomID})
if err != nil || len(closed) == 0 {
@@ -940,9 +931,8 @@ func (s *Store) takeRoomCode(preferredRoomID string) (string, error) {
}
// uniformIndex draws a uniform index below count from 8 random bytes, rejecting
-// the values above the largest multiple of count. The TypeScript computed the
-// limit in BigInt as 2^64 - 2^64 % count; (MaxUint64 % count + 1) % count is
-// the same remainder without the uint64 wrap at a power-of-two count.
+// the incomplete final bucket of the 2^64 possible values.
+// (MaxUint64 % count + 1) % count computes 2^64 % count without overflowing.
func uniformIndex(count int, random func(size int) []byte) (int, error) {
span := uint64(count)
remainder := (math.MaxUint64%span + 1) % span
@@ -1063,8 +1053,7 @@ func (s *Store) ensureInitialized() error {
return nil
}
-// writeDatabase runs a stable-authority write when a database is configured,
-// which is the TypeScript `this.options.database?.write(...)`.
+// writeDatabase runs a stable-authority write when a database is configured.
func (s *Store) writeDatabase(write func() error) error {
if s.database == nil {
return nil
diff --git a/internal/server/route/api.go b/internal/server/route/api.go
index 9c41d2de6..bb9633e80 100644
--- a/internal/server/route/api.go
+++ b/internal/server/route/api.go
@@ -6,9 +6,8 @@ import (
)
// Resource is the caller-owned SFU/overlap allocation the controller stores,
-// moves between collections and compares by pointer identity (TS: the bare
-// Resource type parameter, always used through JS Set semantics). The
-// controller never reads its fields; they exist for the router and tests.
+// moves between collections and compares by pointer identity. The controller
+// never reads its fields; the router owns their meaning and release.
type Resource struct {
Kind ResourceKind
// EndpointPeerID is set for ResourceOverlap.
@@ -25,7 +24,7 @@ type Resource struct {
Released bool
}
-// ResourceKind is the RouteResource discriminator of hybrid-media-router.ts.
+// ResourceKind identifies the allocation owned by the router.
type ResourceKind string
// Resource kinds.
@@ -36,7 +35,7 @@ const (
)
// UpstreamKind discriminates CandidateTuple, CommittedEdge, committedEdgeSeed
-// and QualityUpstream (TS kind: "peer" | "sfu").
+// and QualityUpstream.
type UpstreamKind string
// Upstream kinds.
@@ -45,7 +44,7 @@ const (
UpstreamSfu UpstreamKind = "sfu"
)
-// Transport is the edge transport (TS transport: "direct" | "sfu").
+// Transport is the edge transport.
type Transport string
// Transports.
@@ -54,7 +53,7 @@ const (
TransportSfu Transport = "sfu"
)
-// PublicationPlan is the SFU tuple action (TS publication: "reuse" | "create" | "replace").
+// PublicationPlan is the SFU tuple action.
type PublicationPlan string
// Publication plans.
@@ -93,8 +92,7 @@ const (
TransitionBoundedGap TransitionKind = "bounded-gap"
)
-// SenderQualityState is the sender-side health sample state
-// (TS "unknown" | "healthy" | "degraded").
+// SenderQualityState is the sender-side health sample state.
type SenderQualityState string
// Sender quality states.
@@ -158,7 +156,7 @@ const (
// CandidateTuple is one route candidate: a direct edge from ParentPeerID
// (Kind UpstreamPeer, Transport always TransportDirect, Regenerate = rebuild
// the same edge with a fresh connection) or an SFU edge (Kind UpstreamSfu,
-// Publication set). TS: CandidateTuple.
+// Publication set).
type CandidateTuple struct {
Kind UpstreamKind
ParentPeerID string
@@ -171,7 +169,7 @@ type CandidateTuple struct {
// BeginCurrentCandidate. Edge/Publication are set per Kind
// (direct: neither; sfu-reuse: Edge, Borrowed when it is another holder's
// live subscription and must not be released; sfu-create: Edge and
-// Publication). Overlap is optional for every kind. TS: CandidateReservation.
+// Publication). Overlap is optional for every kind.
type CandidateReservation struct {
Kind ReservationKind
Edge *Resource
@@ -183,7 +181,7 @@ type CandidateReservation struct {
// EndpointRetirement is the producer retirement a bounded-gap transition
// requires: an existing direct edge (RetireEdge: Child*/Parent*/Transport/
// ConnectionID) or the Host publication (RetirePublication: HostSessionID/
-// Generation/ConnectionID). TS: EndpointRetirement.
+// Generation/ConnectionID).
type EndpointRetirement struct {
Kind RetirementKind
ChildPeerID string
@@ -198,15 +196,14 @@ type EndpointRetirement struct {
// EndpointTransition says how the producer's copy budget absorbs the
// candidate. ProducerPeerID is nil only for TransitionNone on an sfu/reuse
-// plan (TS omitted it there and endpointTransitionEquals compares it).
-// Retire is set only for TransitionBoundedGap. TS: EndpointTransition.
+// plan. Retire is set only for TransitionBoundedGap.
type EndpointTransition struct {
Kind TransitionKind
ProducerPeerID *string
Retire *EndpointRetirement
}
-// CandidatePlan is a tuple with its endpoint transition. TS: CandidatePlan.
+// CandidatePlan is a tuple with its endpoint transition.
type CandidatePlan struct {
Tuple CandidateTuple
EndpointTransition EndpointTransition
@@ -215,7 +212,7 @@ type CandidatePlan struct {
// CommittedEdge is a child's one upstream edge in the committed graph.
// Peer edges (Kind UpstreamPeer) set ParentPeerID/ParentSessionID and leave
// Resource nil; SFU edges (Kind UpstreamSfu) set PublicationGeneration and
-// Resource. TS: CommittedEdge.
+// Resource.
type CommittedEdge struct {
Kind UpstreamKind
ChildSessionID string
@@ -227,9 +224,11 @@ type CommittedEdge struct {
Usable bool
PhysicalActive bool
Resource *Resource
+ // The acquisition's session key stays fixed when signaling ownership rebinds.
+ opportunityKey string
}
-// HostPublication is the single live SFU ingress. TS: HostPublication.
+// HostPublication is the single live SFU ingress.
type HostPublication struct {
Generation string
HostSessionID string
@@ -246,7 +245,7 @@ type ConnectionAttemptProgress struct {
Total int
}
-// CurrentAttempt is OperationSnapshot.current: the live candidate attempt.
+// CurrentAttempt describes the operation's live candidate attempt.
// ConnectionAttempt is nil when the attempt has no NAT opportunity.
type CurrentAttempt struct {
Tuple CandidateTuple
@@ -256,7 +255,7 @@ type CurrentAttempt struct {
}
// OperationSnapshot is a deep copy of the in-flight operation plus the
-// derived WakeAtMs. Current is nil without a live attempt. TS: OperationSnapshot.
+// derived WakeAtMs. Current is nil without a live attempt.
type OperationSnapshot struct {
ChildPeerID string
ChildSessionID string
@@ -283,7 +282,7 @@ type RouteSnapshot struct {
Operation *OperationSnapshot
}
-// ReconcileResult is the scheduler outcome. TS: ReconcileResult.
+// ReconcileResult is the scheduler outcome.
type ReconcileResult struct {
Operation *OperationSnapshot
RemovedPeerIDs []string
@@ -292,8 +291,7 @@ type ReconcileResult struct {
}
// SettleResult is the outcome of candidate settlement or active replacement.
-// Committed is true only when CandidateReady committed the attempt (TS: optional
-// committed, read as truthy by the router). TS: SettleResult.
+// Committed is true only when CandidateReady committed the attempt.
type SettleResult struct {
Accepted bool
Committed bool
@@ -302,9 +300,7 @@ type SettleResult struct {
Released []*Resource
}
-// SenderQualityEvidenceResult is the outcome of the two sender-side
-// observers. TS declares it separately with a required committed flag; the
-// Go shape is identical to SettleResult.
+// SenderQualityEvidenceResult is the settlement outcome of sender-side evidence.
type SenderQualityEvidenceResult = SettleResult
// BeginResult is the outcome of the cursor-guarded candidate calls.
@@ -315,7 +311,7 @@ type BeginResult struct {
Released []*Resource
}
-// ParticipantInput describes a connected participant. TS: ParticipantInput.
+// ParticipantInput describes a connected participant.
type ParticipantInput struct {
PeerID string
Role protocol.Role
@@ -325,7 +321,7 @@ type ParticipantInput struct {
// EdgeGuard identifies one committed edge exactly. ParentSessionID is only
// compared for peer edges; callers pass the edge's own ParentSessionID for
-// those and leave it empty for SFU edges. TS: EdgeGuard.
+// those and leave it empty for SFU edges.
type EdgeGuard struct {
ChildPeerID string
ChildSessionID string
@@ -342,8 +338,7 @@ type ParentEdgeGuard struct {
ConnectionID string
}
-// PublicationGuard identifies the Host publication exactly. TS: the inline
-// guard of invalidateHostPublication and retireHostPublication.
+// PublicationGuard identifies the Host publication exactly.
type PublicationGuard struct {
HostSessionID string
RouteRevision int64
@@ -352,13 +347,13 @@ type PublicationGuard struct {
}
// AdoptDirectConnectionInput is an EdgeGuard plus the replacement
-// connection. TS: EdgeGuard & { newConnectionId }.
+// connection.
type AdoptDirectConnectionInput struct {
EdgeGuard
NewConnectionID string
}
-// CandidateGuard identifies the live attempt exactly. TS: CandidateGuard.
+// CandidateGuard identifies the live attempt exactly.
type CandidateGuard struct {
ChildPeerID string
ChildSessionID string
@@ -368,7 +363,7 @@ type CandidateGuard struct {
// CandidateCursorGuard identifies one candidate slot of the current
// operation: it must still be at Cursor with an equal Plan, built at
-// FactVersion on BaseRevision. TS: CandidateCursorGuard.
+// FactVersion on BaseRevision.
type CandidateCursorGuard struct {
ChildPeerID string
ChildSessionID string
@@ -380,8 +375,7 @@ type CandidateCursorGuard struct {
// BeginInput is the BeginCurrentCandidate input. PublicationGeneration and
// PublicationConnectionID are required for sfu create/replace tuples;
-// HostSessionID is required for every SFU tuple (empty = TS undefined,
-// rejected the same way). TS: the inline input of beginCurrentCandidate.
+// HostSessionID is required for every SFU tuple.
type BeginInput struct {
Guard CandidateCursorGuard
NowMs int64
@@ -398,7 +392,7 @@ type CandidateProof struct {
}
// QualityUpstream names the upstream a receiver-side window was measured
-// on: PeerID is set only for UpstreamPeer. TS: RouteQualityEvidenceInput.upstream.
+// on: PeerID is set only for UpstreamPeer.
type QualityUpstream struct {
Kind UpstreamKind
PeerID string
@@ -445,7 +439,7 @@ type SfuPublisherQualityEvidenceInput struct {
}
// Options configures a Controller. DebugRoomID empty disables the sanitised
-// route debug events. TS: ControllerOptions.
+// route debug events.
type Options struct {
HostPeerID string
DebugRoomID string
@@ -498,5 +492,5 @@ type Controller struct {
retiringPublicationGenerations ordered.Map[string, struct{}]
sfuBootstrapIntent *sfuBootstrapIntent
consumedSfuBootstrapOpportunities ordered.Map[string, int]
- rootConvergenceRootPeerID string // "" = TS undefined
+ rootConvergenceRootPeerID string
}
diff --git a/internal/server/route/candidates.go b/internal/server/route/candidates.go
index 931e3d8d0..b6afa2ab8 100644
--- a/internal/server/route/candidates.go
+++ b/internal/server/route/candidates.go
@@ -353,8 +353,7 @@ func (c *Controller) descendantsOf(peerID string) []string {
return descendants
}
-// withSelf ports `this.descendantsOf(peerId).add(peerId)`: the peer itself
-// is appended last unless the walk already reached it.
+// withSelf appends the peer itself unless the descendant walk already reached it.
func withSelf(descendants []string, peerID string) []string {
if slices.Contains(descendants, peerID) {
return descendants
@@ -508,8 +507,8 @@ func stringPtrEquals(left, right *string) bool {
return *left == *right
}
-// In endpointTransitionEquals, `undefined !== HOST` matters for the
-// none producer, hence the pointer comparison.
+// endpointTransitionEquals distinguishes an absent producer from every peer,
+// including the Host, even when no retirement is needed.
func endpointTransitionEquals(left, right EndpointTransition) bool {
if left.Kind != right.Kind {
return false
diff --git a/internal/server/route/controller.go b/internal/server/route/controller.go
index 6463d7327..a180f07d6 100644
--- a/internal/server/route/controller.go
+++ b/internal/server/route/controller.go
@@ -20,18 +20,15 @@ const (
persistentDegradedWindows = int64(protocol.PersistentNativeEdgeDegradedWindows)
)
-// candidateOpportunity is one consumed-ledger entry (TS CandidateOpportunity).
+// candidateOpportunity records the attempts consumed at one candidate rank.
type candidateOpportunity struct {
rank int
startedAttempts int
exhausted bool
}
-// participant is a known peer (TS Participant). sessionID "" is the TS
-// null: disconnected but in grace, or departed. Wire session IDs are
-// validated opaque IDs, so an empty string never names a live session;
-// bootstrapFailureReported and sfuFirstAtNextRoute are TS `true | undefined`
-// and only ever compared to true, so a bool is exact.
+// participant is a known peer. An empty sessionID means disconnected but in
+// grace, or departed; validated wire IDs never name a live session with "".
type participant struct {
peerID string
role protocol.Role
@@ -45,13 +42,12 @@ type participant struct {
sfuFirstAtNextRoute bool
}
-// sessionIs ports `participant.sessionId === id`: a disconnected
-// participant (TS null) never equals any session string.
+// sessionIs never matches a disconnected participant, including an empty id.
func (p *participant) sessionIs(id string) bool {
return p.sessionID != "" && p.sessionID == id
}
-// senderQualityIdentity is TS SenderQualityIdentity.
+// senderQualityIdentity scopes quality evidence to one physical sender.
type senderQualityIdentity struct {
childSessionID string
parentPeerID string
@@ -60,10 +56,8 @@ type senderQualityIdentity struct {
senderIdentity string
}
-// attempt is the live candidate (TS Attempt). The optional strings use ""
-// for undefined (they are only ever set to non-empty values or compared
-// after a truthiness check); the optional timestamps are pointers because
-// 0 is a legal accepted/sample time and `!== undefined` is semantic.
+// attempt is the live candidate. Optional identities use ""; optional
+// timestamps use pointers because zero is a valid accepted/sample time.
type attempt struct {
tuple CandidateTuple
revision int64
@@ -78,16 +72,16 @@ type attempt struct {
transportConnected bool
mediaReady bool
connectionAttempt *ConnectionAttemptProgress
- senderQualityState SenderQualityState // "" = undefined
+ senderQualityState SenderQualityState
senderQualityAcceptedAtMs *int64
- senderQualityConsecutiveHealthyWindows int64 // read only through `?? 0`
- senderQualityConsecutiveDegradedWindows int64 // read only through `?? 0`
+ senderQualityConsecutiveHealthyWindows int64
+ senderQualityConsecutiveDegradedWindows int64
senderQualitySampleTimestampMs *int64
- senderQualityIdentity string // "" = undefined
+ senderQualityIdentity string
relativeQualityApprovedAtMs *int64
}
-// operation is the single in-flight route operation (TS ChildOperation).
+// operation is the single in-flight route operation.
type operation struct {
childPeerID string
childSessionID string
@@ -105,9 +99,8 @@ type operation struct {
current *attempt
}
-// planAt ports `operation.candidates[index]`: nil when out of range. It
-// returns a copy because the candidate array is spliced in place and TS
-// plan objects are never mutated after construction.
+// planAt returns nil when out of range and otherwise copies the plan so callers
+// do not retain a pointer into the candidate slice while it is reordered.
func (o *operation) planAt(index int) *CandidatePlan {
if index < 0 || index >= len(o.candidates) {
return nil
@@ -116,13 +109,13 @@ func (o *operation) planAt(index int) *CandidatePlan {
return &plan
}
-// planAtOrLast ports `operation.candidates[Math.min(cursor, length - 1)]`,
-// which is undefined (nil) for an empty array.
+// planAtOrLast returns the cursor's plan, the final plan if exhausted, or nil
+// when there are no candidates.
func (o *operation) planAtOrLast() *CandidatePlan {
return o.planAt(min(o.cursor, len(o.candidates)-1))
}
-// currentTuple ports `operation.current?.tuple ?? operation.candidates[operation.cursor]?.tuple`.
+// currentTuple prefers the live attempt over the next planned candidate.
func (o *operation) currentTuple() *CandidateTuple {
if o.current != nil {
tuple := o.current.tuple
@@ -134,8 +127,7 @@ func (o *operation) currentTuple() *CandidateTuple {
return nil
}
-// directContinuation is TS DirectContinuation; parentPeerIDs is the retry
-// order and is spliced in place.
+// directContinuation retains the remaining parent retry order behind an SFU edge.
type directContinuation struct {
childSessionID string
sfuConnectionID string
@@ -154,8 +146,8 @@ type sfuBootstrapCarrier struct {
carrierPeerID string
}
-// routeTimingRecord is TS RouteTimingRecord; the optional timestamps are
-// pointers (undefined = never happened, distinct from 0).
+// routeTimingRecord uses nil timestamps for events that have not happened;
+// zero is a valid event time.
type routeTimingRecord struct {
demandAtMs int64
reason DemandReason
@@ -167,8 +159,8 @@ type routeTimingRecord struct {
rejectionBucket RejectionBucket
}
-// routeQualityObservation is TS RouteQualityObservation. upstreamPeerID is
-// "" for the sfu upstream (TS null); lastDecodedProgressAtMs nil is TS null.
+// routeQualityObservation belongs to one receiver edge and presentation epoch.
+// upstreamPeerID is empty for SFU; nil lastDecodedProgressAtMs means no progress.
type routeQualityObservation struct {
childSessionID string
upstreamKind UpstreamKind
@@ -186,8 +178,8 @@ type routeQualityObservation struct {
lastDecodedProgressAtMs *int64
}
-// senderQualityObservation is TS SenderQualityObservation. The consumed
-// ledger is a pointer because `{...previous}` copies carry the same Map.
+// senderQualityObservation copies share the consumed-candidate ledger, so a new
+// sample on the same physical sender cannot reopen an exhausted opportunity.
type senderQualityObservation struct {
childSessionID string
parentPeerID string
@@ -210,7 +202,7 @@ type sfuPublisherQualityObservation struct {
lastAcceptedAtMs int64
}
-// resourceSet is a JS Set: pointer identity plus insertion order.
+// resourceSet deduplicates by pointer identity and preserves insertion order.
type resourceSet struct {
seen map[*Resource]struct{}
list []*Resource
@@ -234,12 +226,12 @@ func (s *resourceSet) has(resource *Resource) bool {
return ok
}
-// resources returns a fresh slice in insertion order (`[...set]`).
+// resources returns a fresh slice in insertion order.
func (s *resourceSet) resources() []*Resource {
return append([]*Resource{}, s.list...)
}
-// concatResources ports `[...a, ...b]` into a fresh, never-nil slice.
+// concatResources concatenates into a fresh, never-nil slice.
func concatResources(parts ...[]*Resource) []*Resource {
out := []*Resource{}
for _, part := range parts {
@@ -248,7 +240,7 @@ func concatResources(parts ...[]*Resource) []*Resource {
return out
}
-// dedupeStrings ports `[...new Set(values)]`: first occurrence wins.
+// dedupeStrings retains each value's first occurrence.
func dedupeStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
@@ -262,8 +254,7 @@ func dedupeStrings(values []string) []string {
return out
}
-// New ports the constructor: panics with "Endpoint media copy capacity must
-// be 1, 2, or 3" or "Route operation timeout must be a positive integer".
+// New constructs a controller and panics on invalid capacity, timeout or SFU policy.
func New(options Options) *Controller {
if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil {
panic(err)
@@ -476,7 +467,7 @@ func (c *Controller) UpsertParticipant(input ParticipantInput, nowMs *int64) []*
}
return released
}
- // A new key appends to the insertion order (TS Map.set at 573).
+ // New participants append to the scheduling order.
c.participants.Set(input.PeerID, &participant{
peerID: input.PeerID,
role: input.Role,
@@ -751,6 +742,9 @@ func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput, now
}
c.clearQualityForParticipant(input.ChildPeerID)
edge.ConnectionID = input.NewConnectionID
+ edge.opportunityKey = c.candidateOpportunityBase(input.ChildPeerID, CandidatePlan{
+ Tuple: CandidateTuple{Kind: edge.Kind, ParentPeerID: edge.ParentPeerID},
+ })
if child, ok := c.participants.Get(input.ChildPeerID); ok {
child.availabilityExhausted = false
}
@@ -899,15 +893,13 @@ func (c *Controller) allocateRevision() int64 {
return c.latestRevision
}
-// isSafeInteger is Number.isSafeInteger for a value that is already integral.
+// isSafeInteger keeps values exactly representable by the Browser's number type.
func isSafeInteger(value int64) bool {
return value >= -maxSafeInteger && value <= maxSafeInteger
}
-// joinOrder is allocated once per participant and preserved across
-// rebindHostIdentity, so two live
-// participants never share one and the TS localeCompare (ICU) tiebreak is
-// unreachable; strings.Compare stands in for it.
+// compareParticipant preserves join order, including across Host rebinding.
+// Live participants have unique orders; peer ID breaks ties in synthetic inputs.
func compareParticipant(left, right *participant) int {
if left.joinOrder != right.joinOrder {
if left.joinOrder < right.joinOrder {
@@ -929,9 +921,7 @@ func stablePairRank(childPeerID, parentPeerID string) int64 {
return rank
}
-// JS Math.round is half-up while math.Round is half-away-from-zero;
-// they differ only for negative halves, which the
-// `rounded < 0` branch drops either way. NaN and +/-Inf drop the window.
+// safeAdd ignores invalid rounded deltas and saturates at the wire integer bound.
func safeAdd(total int64, delta float64) int64 {
rounded := math.Round(delta)
if math.IsNaN(rounded) || math.IsInf(rounded, 0) || rounded < 0 || rounded > float64(maxSafeInteger) {
diff --git a/internal/server/route/graph.go b/internal/server/route/graph.go
index 124c01933..97b9aa501 100644
--- a/internal/server/route/graph.go
+++ b/internal/server/route/graph.go
@@ -53,7 +53,7 @@ func (c *Controller) pruneDepartedLeaves(released *[]*Resource) []string {
changed := true
for changed {
changed = false
- // Live iteration: deleting the visited entry is JS-safe (ordered.Map.All).
+ // ordered.Map.All permits deleting the visited entry during iteration.
for _, current := range c.participants.All() {
if current.role != protocol.RoleViewer || !current.departureConfirmed || len(c.childrenOf(current.peerID)) > 0 {
continue
@@ -167,8 +167,7 @@ func (c *Controller) removePublicationGeneration(generation string) []*Resource
return released
}
-// retainsAnchor is the TS default argument of retireSfuEdge
-// (`this.childrenOf(viewerPeerId).length > 0`), evaluated at call time.
+// retainsAnchor keeps a retiring SFU edge while it still has peer children.
func (c *Controller) retainsAnchor(viewerPeerID string) bool {
return len(c.childrenOf(viewerPeerID)) > 0
}
@@ -191,7 +190,7 @@ func (c *Controller) retireSfuEdge(viewerPeerID string, edge *CommittedEdge, rel
}
// hasRetiringAnchor reports whether an inactive SFU edge still references
-// the generation (the `some` at 4816 and 4880).
+// the generation.
func (c *Controller) hasRetiringAnchor(generation string) bool {
for _, edge := range c.upstreamByViewer.Values() {
if edge.Kind == UpstreamSfu && !edge.PhysicalActive && edge.PublicationGeneration == generation {
diff --git a/internal/server/route/harness_test.go b/internal/server/route/harness_test.go
index 82261b1ff..0041a5565 100644
--- a/internal/server/route/harness_test.go
+++ b/internal/server/route/harness_test.go
@@ -242,6 +242,9 @@ func (c *Controller) hydrateEdge(childPeerID string, edge committedEdgeSeed) {
Usable: edge.Usable,
PhysicalActive: edge.PhysicalActive,
Resource: edge.Resource,
+ opportunityKey: c.candidateOpportunityBase(childPeerID, CandidatePlan{
+ Tuple: CandidateTuple{Kind: edge.Kind, ParentPeerID: edge.ParentPeerID},
+ }),
}
if edge.Kind == UpstreamPeer {
if parent, ok := c.participants.Get(edge.ParentPeerID); ok {
diff --git a/internal/server/route/operation.go b/internal/server/route/operation.go
index 00588ec76..ce9742570 100644
--- a/internal/server/route/operation.go
+++ b/internal/server/route/operation.go
@@ -9,7 +9,7 @@ import (
// validation is the private validateOrAdvance result.
type validation struct {
released []*Resource
- exhaustedChildPeerID string // "" = undefined
+ exhaustedChildPeerID string
expired bool
consumedGuard bool
}
@@ -353,7 +353,7 @@ func (c *Controller) NoteCurrentCandidateRejection(guard CandidateCursorGuard, b
}
// SkipCurrentCandidate consumes the guarded candidate and advances the
-// cursor (TS defaulted bucket to "stale"; Go callers pass RejectionStale).
+// cursor, recording the caller's rejection reason.
func (c *Controller) SkipCurrentCandidate(guard CandidateCursorGuard, nowMs int64, bucket RejectionBucket) BeginResult {
validation := c.validateOrAdvance(nowMs, nil)
op := c.operation
@@ -948,6 +948,7 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
ConnectionID: att.connectionID,
Usable: true,
PhysicalActive: true,
+ opportunityKey: c.candidateOpportunityBase(op.childPeerID, CandidatePlan{Tuple: att.tuple}),
})
c.directContinuations.Delete(op.childPeerID)
} else {
@@ -981,6 +982,7 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
Usable: true,
PhysicalActive: true,
Resource: att.reservation.Edge,
+ opportunityKey: c.candidateOpportunityBase(op.childPeerID, CandidatePlan{Tuple: att.tuple}),
})
var remainingParentPeerIDs []string
if op.reason == DemandSfuBootstrap && oldPeer && old.Usable && old.PhysicalActive {
@@ -1163,7 +1165,7 @@ func (c *Controller) replanRemaining(op *operation, firstTuple, restoreTuple *Ca
}
// operationSnapshot returns a deep copy plus the derived wakeAtMs;
-// Current omits ConnectionAttempt when undefined.
+// Current omits ConnectionAttempt when the attempt has no NAT opportunity.
func (c *Controller) operationSnapshot() *OperationSnapshot {
op := c.operation
if op == nil {
@@ -1440,7 +1442,7 @@ func (c *Controller) consumeDirectContinuationCandidate(op *operation) {
if len(continuation.parentPeerIDs) == 0 {
c.directContinuations.Delete(op.childPeerID)
} else {
- // TS delete + set: the entry moves to the tail (round-robin state).
+ // Give the next continuation a turn before retrying this child.
c.directContinuations.MoveToBack(op.childPeerID, continuation)
}
}
diff --git a/internal/server/route/opportunities.go b/internal/server/route/opportunities.go
index 8407d805e..96e2645db 100644
--- a/internal/server/route/opportunities.go
+++ b/internal/server/route/opportunities.go
@@ -165,19 +165,19 @@ func (c *Controller) consumeQualityOperationCandidates(op *operation) {
// consumeActiveEdgeOpportunity exhausts the invalidated edge's exact tuple at rank 0.
func (c *Controller) consumeActiveEdgeOpportunity(child *participant, edge *CommittedEdge) {
- if edge.Kind == UpstreamPeer {
- key := "peer:" + edge.ParentPeerID + "\x00" + child.sessionID + "\x00" + edge.ParentSessionID
- previous, _ := child.consumedCandidateOpportunities.Get(key)
- child.consumedCandidateOpportunities.Set(key, candidateOpportunity{
- rank: 0,
- startedAttempts: previous.startedAttempts,
- exhausted: true,
- })
+ key := c.candidateOpportunityBase(child.peerID, CandidatePlan{
+ Tuple: CandidateTuple{Kind: edge.Kind, ParentPeerID: edge.ParentPeerID},
+ })
+ // A retained connection can fail after reauthentication. Its old acquisition
+ // must not exhaust the replacement session's still-untried opportunity.
+ if edge.opportunityKey != key {
return
}
- c.consumeCandidateOpportunity(child.peerID, CandidatePlan{
- Tuple: CandidateTuple{Kind: UpstreamSfu, Publication: PublicationReuse},
- EndpointTransition: EndpointTransition{Kind: TransitionNone},
+ previous, _ := child.consumedCandidateOpportunities.Get(key)
+ child.consumedCandidateOpportunities.Set(key, candidateOpportunity{
+ rank: 0,
+ startedAttempts: previous.startedAttempts,
+ exhausted: true,
})
}
diff --git a/internal/server/route/quality.go b/internal/server/route/quality.go
index 5ac7dacbf..e8c9fce42 100644
--- a/internal/server/route/quality.go
+++ b/internal/server/route/quality.go
@@ -386,7 +386,7 @@ func (c *Controller) ObserveSfuPublisherQualityEvidence(input SfuPublisherQualit
}
// settleCandidateQuality folds a sender sample into the live
-// candidate and fails or commits it. senderIdentity "" is the TS null.
+// candidate and fails or commits it. An empty senderIdentity means unknown.
func (c *Controller) settleCandidateQuality(op *operation, att *attempt, state SenderQualityState, acceptedAtMs int64, senderIdentity string, sampleTimestampMs *int64, commitReservation func(CandidateReservation) bool) SenderQualityEvidenceResult {
if att.senderQualityState != state {
c.debug("candidate-quality-observed",
@@ -796,7 +796,7 @@ func qualityUpstreamMatches(edge *CommittedEdge, upstream QualityUpstream) bool
return upstream.Kind == UpstreamSfu
}
-// qualityObservationMatchesEdge treats an empty upstreamPeerID as TypeScript null.
+// qualityObservationMatchesEdge requires an empty upstreamPeerID for SFU.
func qualityObservationMatchesEdge(observation *routeQualityObservation, edge *CommittedEdge) bool {
if edge.Kind == UpstreamPeer {
return observation.upstreamKind == UpstreamPeer && observation.upstreamPeerID == edge.ParentPeerID
diff --git a/internal/server/route/root_convergence_test.go b/internal/server/route/root_convergence_test.go
new file mode 100644
index 000000000..bcbe5d5cd
--- /dev/null
+++ b/internal/server/route/root_convergence_test.go
@@ -0,0 +1,81 @@
+package route
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestRootConvergenceRetainsCommittedRoutesUntilExactProof(t *testing.T) {
+ for _, capacity := range []int{2, 3} {
+ for childCount := 2; childCount <= capacity; childCount++ {
+ for _, outcome := range []string{"commit", "failure", "timeout", "session-replaced", "availability-preempts"} {
+ t.Run(fmt.Sprintf("capacity=%d/children=%d/%s", capacity, childCount, outcome), func(t *testing.T) {
+ routes := newController(capacity, Options{QualityConvergenceEnabled: true})
+ addViewer(routes, B, capacity, nil)
+ routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
+ children := []string{C, D, E}[:childCount]
+ for _, child := range children {
+ addViewer(routes, child, 0, nil)
+ routes.hydrateEdge(child, peerEdge(B, child+"_from_b"))
+ }
+ addViewer(routes, A, capacity, ms(10))
+ routes.Reconcile(10)
+ commitCurrent(t, routes, 11, "a_from_host")
+ op := must(t, routes.Reconcile(13).Operation)
+ eq(t, op.Reason, DemandRootConvergence)
+ prepared := beginOperation(t, routes, BeginInput{
+ NowMs: 14, ConnectionID: "root_candidate", Reservation: direct(),
+ })
+ guard := CandidateGuard{ChildPeerID: op.ChildPeerID, ChildSessionID: op.ChildSessionID,
+ Revision: prepared.Current.Revision, ConnectionID: "root_candidate"}
+
+ // Another child's report cannot authorize the prepared child, even
+ // when it names the candidate's revision and physical connection.
+ other := children[0]
+ if other == op.ChildPeerID {
+ other = children[1]
+ }
+ eq(t, routes.ObserveSenderQualityEvidence(senderSample(A, other,
+ prepared.Current.Revision, "root_candidate", "candidate-sender",
+ SenderQualityHealthy, 15), nil).Accepted, false)
+ ready := routes.CandidateReady(guard, 16, nil, CandidateProof{})
+ eq(t, ready.Accepted, true)
+ eq(t, ready.Committed, false)
+
+ switch outcome {
+ case "commit":
+ eq(t, routes.ObserveSenderQualityEvidence(senderSample(A, op.ChildPeerID,
+ prepared.Current.Revision, "root_candidate", "candidate-sender",
+ SenderQualityHealthy, 17), nil).Committed, true)
+ case "failure":
+ eq(t, routes.CandidateFailed(guard, 17).Accepted, true)
+ case "timeout":
+ eq(t, routes.OperationExpired(op.DeadlineAtMs).Accepted, true)
+ case "session-replaced":
+ routes.UpsertParticipant(viewerInput(A, "replacement_session", capacity), ms(17))
+ eq(t, routes.CandidateReady(guard, 18, nil, CandidateProof{}).Accepted, false)
+ case "availability-preempts":
+ addViewer(routes, F, 0, ms(17))
+ next := must(t, routes.Reconcile(18).Operation)
+ eq(t, next.ChildPeerID, F)
+ eq(t, next.Reason, DemandJoin)
+ eq(t, routes.CandidateReady(guard, 19, nil, CandidateProof{}).Accepted, false)
+ }
+ for _, child := range children {
+ edge := edgeOf(t, routes, child)
+ if child == op.ChildPeerID && outcome == "commit" {
+ eq(t, edge.ParentPeerID, A)
+ eq(t, edge.ConnectionID, "root_candidate")
+ } else {
+ eq(t, edge.ParentPeerID, B)
+ eq(t, edge.ConnectionID, child+"_from_b")
+ }
+ eq(t, edge.Usable, true)
+ eq(t, edge.PhysicalActive, true)
+ }
+ routes.assertGraph()
+ })
+ }
+ }
+ }
+}
diff --git a/internal/server/route/session_opportunity_test.go b/internal/server/route/session_opportunity_test.go
new file mode 100644
index 000000000..4450fdc8a
--- /dev/null
+++ b/internal/server/route/session_opportunity_test.go
@@ -0,0 +1,64 @@
+package route
+
+import "testing"
+
+func TestRetainedEdgeFailurePreservesNewSessionOpportunity(t *testing.T) {
+ for _, upstream := range []UpstreamKind{UpstreamPeer, UpstreamSfu} {
+ for _, changed := range []string{"none", "child", "host"} {
+ t.Run(string(upstream)+"/"+changed, func(t *testing.T) {
+ routes := newController(2, Options{SfuEnabled: true})
+ addViewer(routes, A, 0, nil)
+ if upstream == UpstreamSfu {
+ routes.hydrateHostPublication("publication", res("publication_resource"), "")
+ routes.hydrateEdge(A, sfuEdge("publication", "retained_connection", "subscription"))
+ } else {
+ routes.hydrateEdge(A, peerEdge(HOST, "retained_connection"))
+ }
+ switch changed {
+ case "child":
+ routes.UpsertParticipant(viewerInput(A, "replacement_child_session", 0), nil)
+ case "host":
+ routes.UpsertParticipant(hostInput("replacement_host_session", 2), nil)
+ }
+ // Reauthentication keeps healthy media and its physical identity.
+ edge := edgeOf(t, routes, A)
+ eq(t, edge.Usable, true)
+ eq(t, edge.ConnectionID, "retained_connection")
+ noOperation(t, routes.Reconcile(0).Operation)
+ eq(t, routes.InvalidateEdge(EdgeGuard{
+ ChildPeerID: A, ChildSessionID: edge.ChildSessionID,
+ ParentSessionID: edge.ParentSessionID,
+ RouteRevision: routes.Snapshot().Revision, ConnectionID: edge.ConnectionID,
+ }, ms(1)), true)
+ operation := must(t, routes.Reconcile(1).Operation)
+ eq(t, hasTuple(operation, func(tuple CandidateTuple) bool {
+ return tuple.Kind == upstream
+ }), changed != "none")
+ })
+ }
+ }
+}
+
+func TestRebuiltDirectConnectionConsumesCurrentSessionOpportunity(t *testing.T) {
+ routes := newController(2, Options{SfuEnabled: true})
+ addViewer(routes, A, 0, ms(0))
+ routes.Reconcile(0)
+ commitCurrent(t, routes, 1, "original_connection")
+ routes.UpsertParticipant(viewerInput(A, "replacement_child_session", 0), nil)
+ edge := edgeOf(t, routes, A)
+ result := routes.AdoptDirectConnection(AdoptDirectConnectionInput{
+ EdgeGuard: EdgeGuard{
+ ChildPeerID: A, ChildSessionID: edge.ChildSessionID,
+ ParentSessionID: edge.ParentSessionID, RouteRevision: routes.Snapshot().Revision,
+ ConnectionID: edge.ConnectionID,
+ },
+ NewConnectionID: "rebuilt_connection",
+ }, 3)
+ eq(t, result.Accepted, true)
+ eq(t, routes.InvalidateEdge(EdgeGuard{
+ ChildPeerID: A, ChildSessionID: edge.ChildSessionID,
+ ParentSessionID: edge.ParentSessionID, RouteRevision: result.ActiveRevision,
+ ConnectionID: "rebuilt_connection",
+ }, ms(4)), true)
+ eq(t, hasTuple(must(t, routes.Reconcile(4).Operation), isPeer), false)
+}
diff --git a/internal/server/route/timing.go b/internal/server/route/timing.go
index 495bf7230..28d09f677 100644
--- a/internal/server/route/timing.go
+++ b/internal/server/route/timing.go
@@ -140,7 +140,7 @@ func (c *Controller) diagnosticParent(edge *CommittedEdge, ordinals map[string]i
return protocol.RouteDiagnosticParent{Kind: "viewer", Ordinal: protocol.Int(ordinal)}
}
-// diagnosticQuality returns nil (TS null) unless the observation is
+// diagnosticQuality returns nil unless the observation is
// fresh, non-empty and still describes the current edge.
func (c *Controller) diagnosticQuality(childPeerID string, edge *CommittedEdge, nowMs int64) *protocol.RouteDiagnosticQuality {
observation := c.qualityObservations[childPeerID]
@@ -170,9 +170,8 @@ func (c *Controller) diagnosticQuality(childPeerID string, edge *CommittedEdge,
}
}
-// elapsedMs returns nil (TS null) when the end precedes the start or
-// the difference is not a safe integer; the inputs are integral so
-// Math.floor is the identity.
+// elapsedMs returns nil when the end precedes the start or the difference
+// exceeds the wire integer bound.
func elapsedMs(startedAtMs, endedAtMs int64) *protocol.Int {
elapsed := endedAtMs - startedAtMs
if !isSafeInteger(elapsed) || elapsed < 0 {
diff --git a/internal/server/sfu/admission.go b/internal/server/sfu/admission.go
index 943057dea..b96e061ce 100644
--- a/internal/server/sfu/admission.go
+++ b/internal/server/sfu/admission.go
@@ -8,36 +8,30 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
-// ResourceFence is SfuResourceFence. It is a comparable value, so the
-// TypeScript cloneFence/sameFence helpers are a plain copy and `==`.
+// ResourceFence identifies one publication generation within a room share.
type ResourceFence struct {
RoomID string
ShareGeneration string
PublicationGeneration string
}
-// SubscriptionFence is SfuSubscriptionFence, which extends SfuResourceFence.
-// The embedded value is the `{ ...fence, viewerPeerId }` spread the router
-// uses to derive one from a publication fence.
+// SubscriptionFence identifies one Viewer's connection to that publication.
type SubscriptionFence struct {
ResourceFence
ViewerPeerID string
ConnectionID string
}
-// Usage is SfuResourceUsage.
type Usage struct {
Ingress int
Egress int
}
-// AdmissionOptions is SfuResourceAdmissionOptions.
type AdmissionOptions struct {
IngressCapacity int
EgressCapacity int
}
-// resourceState is the TypeScript ResourceState union.
type resourceState string
const (
@@ -54,21 +48,19 @@ type subscriptionEntry struct {
type publicationEntry struct {
fence ResourceFence
state resourceState
- // Ordering site: subscriptions are only counted, looked up by viewer and
- // state-scanned, so their iteration order is never observable. A plain map
- // is enough here; the two maps above it are ordered.
+ // Subscriptions are counted and looked up by exact connection; their
+ // iteration order does not affect drain order.
subscriptions map[string]*subscriptionEntry
}
-// roomPublications is RoomResources.publications. Ordering site: commitPublication
-// returns the drained generations and beginDrainRoom returns every generation in
-// insertion order, both of which the router turns into drain tasks.
+// Publication insertion order determines the router's physical drain order.
type roomPublications = ordered.Map[string, *publicationEntry]
-// Admission is SfuResourceAdmission: the ingress/egress ledger for the embedded SFU. It is not safe for concurrent use; the signaling server calls it
-// under its global mutex, exactly as Node's single thread did.
+// Admission tracks reserved, committed and draining SFU capacity. The signaling
+// server serializes access under its mutex; capacity stays charged until physical
+// retirement is confirmed through CompleteDrain or CompleteSubscriptionDrain.
type Admission struct {
- // Ordering site: beginDrainAll walks the rooms in insertion order.
+ // BeginDrainAll also preserves room insertion order.
rooms ordered.Map[string, *roomPublications]
ingressCapacity int
egressCapacity int
@@ -76,9 +68,7 @@ type Admission struct {
egressInUse int
}
-// NewAdmission mirrors the SfuResourceAdmission constructor. An invalid
-// capacity panics where the TypeScript throws: the capacities come from a
-// constant and from validated configuration, so this is an assertion.
+// NewAdmission requires capacities from validated configuration.
func NewAdmission(options AdmissionOptions) *Admission {
assertPositiveSafeInteger(options.IngressCapacity, "SFU ingress capacity")
assertPositiveSafeInteger(options.EgressCapacity, "SFU egress capacity")
@@ -145,8 +135,9 @@ func (admission *Admission) ReserveSubscription(fence SubscriptionFence) bool {
return true
}
-// CommitPublication returns false as its second result where TypeScript returns
-// null; an empty slice with true is its empty array.
+// CommitPublication promotes the reserved publication and subscriptions and
+// returns older committed publications for physical retirement. The boolean
+// reports acceptance; a repeated commit succeeds without scheduling another drain.
func (admission *Admission) CommitPublication(fence ResourceFence) ([]ResourceFence, bool) {
assertFence(fence)
room, _ := admission.rooms.Get(fence.RoomID)
@@ -366,8 +357,6 @@ func assertSubscriptionFence(fence SubscriptionFence) {
}
}
-// Go's int makes the Number.isSafeInteger fractional case unrepresentable; only the
-// range checks survive.
func assertPositiveSafeInteger(value int, name string) {
if value <= 0 || int64(value) > protocol.MaxSafeInteger {
panic(name + " must be a positive safe integer")
diff --git a/internal/server/sfu/media.go b/internal/server/sfu/media.go
index 63452c0c0..6c4a0184f 100644
--- a/internal/server/sfu/media.go
+++ b/internal/server/sfu/media.go
@@ -4,10 +4,12 @@ import (
"context"
"errors"
"slices"
+ "strconv"
"strings"
"sync"
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
"github.com/pion/interceptor"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v4"
@@ -125,7 +127,10 @@ func validPublicationMedia(metadata PublicationMedia) bool {
return false
}
for index, format := range metadata.Formats {
- if format.Width == 0 || format.Height == 0 || format.Bitrate == 0 ||
+ // These are this SFU's admission bounds, not the descriptive wire format.
+ // Forward the full current Browser/Native bitrate range without transcoding.
+ if format.Width == 0 || format.Width > 8192 || format.Height == 0 || format.Height > 8192 ||
+ format.Bitrate == 0 || format.Bitrate > 240_000_000 ||
len(metadata.RIDs[index]) > 16 ||
(metadata.RIDs[index] == "" && len(metadata.RIDs) != 1) ||
slices.Contains(metadata.RIDs[:index], metadata.RIDs[index]) {
@@ -342,9 +347,9 @@ func (media *Media) AcceptPublisherOffer(fence ResourceFence, connectionID strin
return webrtc.SessionDescription{}, errors.New("SFU publisher codec does not match its declaration")
}
if transceiver.Kind() == webrtc.RTPCodecTypeAudio {
- selected[0].SDPFmtpLine = "minptime=10;useinbandfec=1;stereo=1;usedtx=0"
+ selected[0].SDPFmtpLine = "minptime=10;useinbandfec=1;stereo=1;usedtx=0;maxaveragebitrate=" + strconv.Itoa(protocol.MaxAudioBitrate)
}
- if err = transceiver.SetCodecPreferences(selected[:1]); err != nil {
+ if err = transceiver.SetCodecPreferences(forwarding.WithRTX(selected[0], parameters.Codecs)); err != nil {
return webrtc.SessionDescription{}, err
}
}
@@ -390,15 +395,6 @@ func (media *Media) UpdatePublication(fence ResourceFence, connectionID string,
metadata.Formats, metadata.RIDs = slices.Clone(metadata.Formats), slices.Clone(metadata.RIDs)
publication.metadata = metadata
publication.wakeDemand()
- bitrate := uint32(0)
- if metadata.Audio {
- bitrate = metadata.AudioBitrate
- }
- for _, subscriber := range publication.subscribers {
- if subscriber.transport != nil {
- subscriber.transport.SetAudioBitrate(bitrate)
- }
- }
return nil
}
@@ -459,7 +455,7 @@ func (publication *mediaPublication) consumeTrack(track *webrtc.TrackRemote, rec
go publication.readRTCP(track, receiver, source, layer)
defer publication.readers.Done()
for {
- packet, _, err := track.ReadRTP()
+ packet, _, err := forwarding.ReadRTP(track)
if err != nil {
return
}
@@ -577,9 +573,6 @@ func (media *Media) PrepareSubscriber(ctx context.Context, fence SubscriptionFen
if err != nil {
return webrtc.SessionDescription{}, err
}
- if metadata.Audio {
- transport.SetAudioBitrate(metadata.AudioBitrate)
- }
publication.mu.Lock()
if publication.closed || publication.failure != nil || publication.subscribers[key] != subscriber || ctx.Err() != nil || lifetime.Err() != nil {
publication.mu.Unlock()
@@ -639,6 +632,11 @@ func (media *Media) ApplySubscriberAnswer(fence SubscriptionFence, connectionID
if answer.Type != webrtc.SDPTypeAnswer {
return errors.New("SFU subscriber requires an answer")
}
+ if subscriber.transport.AudioSender != nil {
+ if err := forwarding.CheckAudioReceiveLimit(answer.SDP, protocol.MaxAudioBitrate); err != nil {
+ return err
+ }
+ }
return subscriber.transport.PC.SetRemoteDescription(answer)
}
diff --git a/internal/server/sfu/media_test.go b/internal/server/sfu/media_test.go
index 8720cb0d2..5ef40aee5 100644
--- a/internal/server/sfu/media_test.go
+++ b/internal/server/sfu/media_test.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
+ "strings"
"sync"
"sync/atomic"
"testing"
@@ -29,6 +30,57 @@ func TestMediaOnePublicationTwoSubscribersAndExactRetirement(t *testing.T) {
}
}
+func TestUnsupportedPublicationDescriptionDoesNotAllocate(t *testing.T) {
+ service := NewMedia(MediaOptions{})
+ defer service.Close()
+ fence := ResourceFence{RoomID: "1234", ShareGeneration: "share_12345678", PublicationGeneration: "publication_12345678"}
+ for _, format := range []forwarding.LayerFormat{
+ {Width: 32768, Height: 32768, Bitrate: 5_000_000},
+ {Width: 1920, Height: 1080, Bitrate: 240_000_001},
+ {Width: 0xffff_ffff, Height: 0xffff_ffff, Bitrate: 0xffff_ffff},
+ } {
+ metadata := PublicationMedia{Codec: "vp8", RIDs: []string{""}, Formats: []forwarding.LayerFormat{format}}
+ if err := service.PreparePublication(fence, "publisher_12345678", metadata); err == nil {
+ t.Fatal("unsupported format was admitted")
+ }
+ if service.HasPublication(fence, "publisher_12345678") {
+ t.Fatal("unsupported format allocated a publication")
+ }
+ }
+}
+
+func TestPublicationAdmitsSupportedBitrateCeilings(t *testing.T) {
+ service := NewMedia(MediaOptions{})
+ defer service.Close()
+ fence := ResourceFence{RoomID: "1234", ShareGeneration: "share_12345678", PublicationGeneration: "publication_12345678"}
+ metadata := PublicationMedia{Codec: "h264", RIDs: []string{""},
+ Formats: []forwarding.LayerFormat{{Width: 1920, Height: 1080, Bitrate: 240_000_000}}}
+ if err := service.PreparePublication(fence, "publisher_12345678", metadata); err != nil {
+ t.Fatalf("prepare supported publisher: %v", err)
+ }
+ publication, err := service.publication(fence, "publisher_12345678")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pc := publication.pc
+ for _, bitrate := range []uint32{5_000_000, 100_000_001, 240_000_000} {
+ metadata.Formats[0].Bitrate = bitrate
+ if err := service.UpdatePublication(fence, "publisher_12345678", metadata); err != nil {
+ t.Fatalf("update supported ceiling %d: %v", bitrate, err)
+ }
+ if publication.pc != pc || publication.metadata.Formats[0].Bitrate != bitrate {
+ t.Fatal("quality update replaced the publication or lost its bitrate ceiling")
+ }
+ }
+ metadata.Formats[0].Bitrate++
+ if err := service.UpdatePublication(fence, "publisher_12345678", metadata); err == nil {
+ t.Fatal("unsupported update was admitted")
+ }
+ if publication.pc != pc || publication.metadata.Formats[0].Bitrate != 240_000_000 {
+ t.Fatal("rejected update changed the existing publication")
+ }
+}
+
func TestMediaDemandBootstrapsWaitingSubscriberAndStopsAfterExactClose(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
@@ -223,6 +275,12 @@ func runMediaFanout(t *testing.T, codec string, layerCount int) {
t.Fatal(ctx.Err())
}
check(publisher.SetRemoteDescription(*publication.pc.LocalDescription()))
+ parameters := publication.pc.GetTransceivers()[0].Receiver().GetParameters()
+ if len(parameters.Codecs) != 2 || parameters.Codecs[0].MimeType != capability.MimeType ||
+ parameters.Codecs[1].MimeType != webrtc.MimeTypeRTX ||
+ parameters.Codecs[1].SDPFmtpLine != fmt.Sprintf("apt=%d", parameters.Codecs[0].PayloadType) {
+ t.Fatalf("publisher lost its selected codec/repair pair: %v", parameters.Codecs)
+ }
mid := publisher.GetTransceivers()[0].Mid()
headerExtensions := videoSender.GetParameters().HeaderExtensions
stopSamples := make(chan struct{})
@@ -296,9 +354,45 @@ func runMediaFanout(t *testing.T, codec string, layerCount int) {
}
})
check(receiver.SetRemoteDescription(offer))
+ for _, transceiver := range receiver.GetTransceivers() {
+ if transceiver.Kind() != webrtc.RTPCodecTypeAudio {
+ continue
+ }
+ for _, codec := range transceiver.Receiver().GetParameters().Codecs {
+ if codec.MimeType == webrtc.MimeTypeOpus {
+ codec.SDPFmtpLine = "stereo=1;maxaveragebitrate=510000"
+ check(transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec}))
+ break
+ }
+ }
+ }
answer, answerErr := receiver.CreateAnswer(nil)
check(answerErr)
- check(service.ApplySubscriberAnswer(subscription, "subscriber_123456", waitGathering(receiver, answer)))
+ answer = waitGathering(receiver, answer)
+ if index == 1 {
+ // Admission of one limited receiver must not bind its incompatible
+ // audio or disturb the publication and its already-playing sibling.
+ limited := answer
+ limited.SDP = strings.ReplaceAll(limited.SDP, "maxaveragebitrate=510000", "maxaveragebitrate=192000")
+ if limited.SDP == answer.SDP {
+ t.Fatal("receiver fixture did not declare its Opus limit")
+ }
+ if service.ApplySubscriberAnswer(subscription, "subscriber_123456", limited) == nil {
+ t.Fatal("shared audio accepted an insufficient receiver limit")
+ }
+ if physical.transport.PC.RemoteDescription() != nil {
+ t.Fatal("rejected answer was applied before admission")
+ }
+ beforeVideo, beforeAudio := videoPackets[0].Load(), audioPackets[0].Load()
+ for videoPackets[0].Load() < beforeVideo+3 || audioPackets[0].Load() < beforeAudio+3 {
+ select {
+ case <-time.After(10 * time.Millisecond):
+ case <-ctx.Done():
+ t.Fatal("receiver rejection interrupted its healthy sibling")
+ }
+ }
+ }
+ check(service.ApplySubscriberAnswer(subscription, "subscriber_123456", answer))
}
for videoPackets[0].Load() < 3 || videoPackets[1].Load() < 3 ||
audioPackets[0].Load() < 3 || audioPackets[1].Load() < 3 {
diff --git a/internal/server/signal/presence.go b/internal/server/signal/presence.go
index 5859ba6ef..94f484a58 100644
--- a/internal/server/signal/presence.go
+++ b/internal/server/signal/presence.go
@@ -14,9 +14,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/room"
)
-// viewerKey is the TS `${roomId}:${peerId}` used by the grace timers and the
-// viewer quality evidence maps (viewerGraceKey and viewerConnectionKey were
-// the same expression).
+// viewerKey scopes grace timers and quality evidence to one room participant.
func viewerKey(roomID, peerID string) string { return roomID + ":" + peerID }
// sendViewerPresence lists the host entry first
@@ -158,9 +156,8 @@ func (s *Server) handleDisconnect(sess *session) {
s.sendViewerPresence(disconnected.RoomID)
- // The viewer grace timer. The registered-pointer check stands in
- // for clearTimeout; the store's removeDisconnectedViewer is the identity
- // guard against a viewer that reconnected meanwhile.
+ // The registered pointer rejects stale grace callbacks; the store then
+ // guards against removing a Viewer that has already reconnected.
roomID, peerID := disconnected.RoomID, disconnected.PeerID
key := viewerKey(roomID, peerID)
timer := &graceTimer{}
diff --git a/internal/server/signal/punch_test.go b/internal/server/signal/punch_test.go
new file mode 100644
index 000000000..806bd4072
--- /dev/null
+++ b/internal/server/signal/punch_test.go
@@ -0,0 +1,66 @@
+package signal
+
+import (
+ "testing"
+
+ "github.com/TNTcraftHIM/Piik/internal/server/protocol"
+)
+
+func TestPunchAuthorizationBelongsOnlyToCurrentPeerRetry(t *testing.T) {
+ h := newRouterHarness(t, routerHarnessOptions{capacity: 2})
+ created := h.createRoom()
+ host := h.connectHost(created, &protocol.RoutePolicy{PeerOnly: true, NatPrediction: true}, "", "")
+ viewer := h.connectViewer(created, "punch-viewer")
+ h.locked(func() { h.doComplete(host); h.doComplete(viewer) })
+ for attempt := 1; attempt <= 2; attempt++ {
+ prepared := h.waitPreparedMatching(viewer.sessionID, "peer retry", func(message protocol.RouteUpdatePrepareMessage) bool {
+ return message.Candidate.ConnectionAttempt != nil && int(message.Candidate.ConnectionAttempt.Current) == attempt
+ })
+ h.locked(func() {
+ base := peerSignalInput{roomID: created.RoomID, sourcePeerID: host.peerID, sourceSessionID: host.sessionID,
+ targetPeerID: viewer.peerID, targetSessionID: viewer.sessionID, connectionID: prepared.Candidate.ConnectionID}
+ for _, phase := range []string{"prepare", "start", "ready", "complete", "offer"} {
+ input := base
+ input.signalKind, input.punchPhase = "punch", phase
+ if phase == "ready" || phase == "complete" {
+ input.sourcePeerID, input.targetPeerID = input.targetPeerID, input.sourcePeerID
+ input.sourceSessionID, input.targetSessionID = input.targetSessionID, input.sourceSessionID
+ }
+ if phase == "offer" {
+ input.signalKind, input.punchPhase, input.descriptionType, input.punched = "description", "", "offer", true
+ }
+ want := signalAuthorizationDenied
+ if attempt > 1 {
+ want = signalAuthorizationProbe
+ }
+ if got := h.router.peerSignalAuthorization(input); got != want {
+ t.Fatalf("attempt %d %s: got %d want %d", attempt, phase, got, want)
+ }
+ stale := input
+ stale.connectionID = "retired_connection"
+ if h.router.peerSignalAuthorization(stale) != signalAuthorizationDenied {
+ t.Fatal("stale generation authorized")
+ }
+ stale = input
+ stale.sourceSessionID = "retired_session"
+ if h.router.peerSignalAuthorization(stale) != signalAuthorizationDenied {
+ t.Fatal("stale session authorized")
+ }
+ input.sourcePeerID, input.targetPeerID = input.targetPeerID, input.sourcePeerID
+ input.sourceSessionID, input.targetSessionID = input.targetSessionID, input.sourceSessionID
+ if h.router.peerSignalAuthorization(input) != signalAuthorizationDenied {
+ t.Fatalf("wrong phase direction authorized: %s", phase)
+ }
+ }
+ if attempt == 1 {
+ h.doFailed(viewer, routeFailedMessage(int64(prepared.Revision), "prepare", prepared.Candidate.ConnectionID))
+ } else {
+ h.doReady(viewer, int64(prepared.Revision))
+ base.signalKind, base.punchPhase = "punch", "prepare"
+ if h.router.peerSignalAuthorization(base) != signalAuthorizationDenied {
+ t.Fatal("committed edge authorized another preparation")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/server/signal/router.go b/internal/server/signal/router.go
index f6cda6bba..391ae525d 100644
--- a/internal/server/signal/router.go
+++ b/internal/server/signal/router.go
@@ -1,7 +1,7 @@
package signal
// The router owns no lock. signal.Server.mu (routerOptions.mu) is held by the
-// caller of every method except close, hooks are called with it held, and the
+// caller of every method, hooks are called with it held, and the
// goroutines the router starts (pump driver, media prepares, drains and timer
// callbacks) take it themselves. After unlocked I/O, revalidate the owning
// room, operation and resource before committing effects under the lock.
@@ -52,16 +52,16 @@ func routeDebugSink() func(string, ...any) {
return slog.Info
}
-// sfuFallback is SfuFallbackOptions. A zero timeout means the TS default.
+// sfuFallback supplies embedded media and admission; zero selects the default timeout.
type sfuFallback struct {
media sfu.Runtime
admission *sfu.Admission
prepareTimeoutMs int64
}
-// routerHooks are the signaling server callbacks of HybridMediaRouterOptions.
+// routerHooks are callbacks into the signaling server.
// Every hook is called with mu held and must not block. shareGeneration
-// returns "" where the TS returned undefined; routesChanged may be nil.
+// returns "" when no share exists; routesChanged may be nil.
type routerHooks struct {
sendToSession func(sessionID string, message protocol.ServerMessage)
shareGeneration func(roomID string) string
@@ -81,8 +81,7 @@ type routerOptions struct {
afterFunc func(time.Duration, func()) func() bool
}
-// authenticatedRouteParticipant is AuthenticatedRouteParticipant; routePolicy
-// is nil where the TS key was absent.
+// authenticatedRouteParticipant carries admitted identity and optional Host policy.
type authenticatedRouteParticipant struct {
roomID string
role protocol.Role
@@ -91,22 +90,21 @@ type authenticatedRouteParticipant struct {
routePolicy *protocol.RoutePolicy
}
-// hybridAuthenticationState is HybridAuthenticationState.
+// hybridAuthenticationState supplies the joining session's route assignment.
type hybridAuthenticationState struct {
routeRevision int64
routeAssignment protocol.ParticipantRouteAssignment
}
-// activeViewerMediaEdge is ActiveViewerMediaEdge; upstream is peer or sfu.
+// activeViewerMediaEdge identifies one Viewer's active Peer or SFU upstream.
type activeViewerMediaEdge struct {
revision int64
connectionID string
upstream protocol.MediaRouteUpstream
}
-// signalAuthorization is the `boolean | "probe" | undefined` result of
-// peerSignalAuthorization. signalAuthorizationUnassigned is the TS undefined:
-// the signaling server falls back to the committed-edge assignment.
+// signalAuthorization is the peerSignalAuthorization result. Unassigned makes
+// the signaling server check the committed-edge assignment.
type signalAuthorization int
const (
@@ -116,8 +114,8 @@ const (
signalAuthorizationProbe
)
-// peerSignalInput is the peerSignalAuthorization input; descriptionType is
-// "" where the TS key was absent.
+// peerSignalInput supplies session/connection identity for signal authorization.
+// descriptionType is empty for signals without an SDP description.
type peerSignalInput struct {
roomID string
sourcePeerID string
@@ -127,10 +125,11 @@ type peerSignalInput struct {
connectionID string
signalKind string
descriptionType string
+ punchPhase string
+ punched bool
}
-// peerSignalDebugInput is the debugPeerSignal input; candidateOrigin and
-// descriptionType are "" where the TS keys were absent.
+// peerSignalDebugInput supplies signal provenance; optional fields are empty.
type peerSignalDebugInput struct {
roomID string
sourcePeerID string
@@ -141,8 +140,8 @@ type peerSignalDebugInput struct {
authorization signalAuthorization
}
-// roomRuntime is RoomRuntime. pumping is the `pump` promise used as a
-// single-flight token; deadlineGeneration is bumped by every clearDeadline so
+// roomRuntime owns one controller. pumping prevents concurrent drivers;
+// deadlineGeneration is bumped by every clearDeadline so
// a callback that lost the Stop race sees it is stale.
type roomRuntime struct {
hostPeerID string
@@ -155,7 +154,7 @@ type roomRuntime struct {
deadlineGeneration uint64
}
-// router is HybridMediaRouter.
+// router applies controller decisions through room signaling and media resources.
type router struct {
mu *sync.Mutex
store *room.Store
@@ -172,13 +171,12 @@ type router struct {
resourceWaiters ordered.Map[string, struct{}]
sfuDrainTasks ordered.Map[sfu.SubscriptionFence, *sfuDrainTask]
// inflight counts the goroutines that will re-acquire mu: the pump
- // drivers, fresh SFU config issues and drains. It is the TS microtask
- // backlog; tests wait for it to reach zero where the TS awaited.
+ // drivers, fresh SFU config issues and drains.
inflight int
closing bool
}
-// newRouter is the HybridMediaRouter constructor.
+// newRouter shares the server's lock and supplies clock defaults.
func newRouter(options routerOptions) *router {
if err := protocol.AssertEndpointMediaCopyCapacity(options.endpointMediaCopyCapacity); err != nil {
panic(err)
@@ -205,12 +203,9 @@ func newRouter(options routerOptions) *router {
return r
}
-// io is one TS `await` on external work: mu is released around fn and always
-// re-acquired, including while a panic unwinds. That last part is the reason
-// this is a helper rather than an inline Unlock/Lock pair: recoverPump turns a
-// panic raised inside such a window into the TS pump rejection, and its
-// caller's `defer r.mu.Unlock()` would otherwise unlock a mutex nobody holds
-// (an unrecoverable runtime error). Nothing may touch router state inside fn.
+// io releases mu for external work and reacquires it even while a panic unwinds.
+// This keeps the caller's deferred unlock valid when recoverPump catches the
+// failure. Nothing may touch router state inside fn.
func (r *router) io(fn func()) {
r.mu.Unlock()
defer r.mu.Lock()
@@ -266,9 +261,7 @@ func (r *router) connectParticipant(input authenticatedRouteParticipant) hybridA
if rm.controller != nil && rm.hostPeerID != input.peerID {
r.clearDeadline(rm)
// rm.hostPeerID always equals the controller's, so this branch
- // takes RebindHostIdentity's clearing path: every committed edge
- // goes, which is what the TS per-viewer deleteConnectionId loop
- // then restated into the mirror.
+ // takes RebindHostIdentity's clearing path: every committed edge goes.
r.releaseResources(rm.controller.RebindHostIdentity(input.peerID, input.sessionID, r.nowPtr()))
}
rm.hostPeerID = input.peerID
@@ -573,6 +566,9 @@ const candidateConnectionIDPrefix = "route_"
func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorization {
rm, _ := r.rooms.Get(input.roomID)
if rm == nil || rm.controller == nil {
+ if input.signalKind == "punch" || input.punched {
+ return signalAuthorizationDenied
+ }
return signalAuthorizationUnassigned
}
controller := rm.controller
@@ -593,16 +589,32 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
}
if hasParent && hasChild &&
parent.SessionID == parentSession &&
- child.SessionID == childSession &&
- (input.signalKind == "candidate" ||
+ child.SessionID == childSession {
+ if input.signalKind == "punch" || input.punched {
+ if current.ConnectionAttempt == nil || current.ConnectionAttempt.Current <= 1 {
+ return signalAuthorizationDenied
+ }
+ if (input.punched && parentToChild && input.descriptionType == "offer") ||
+ (parentToChild && (input.punchPhase == "prepare" || input.punchPhase == "start")) ||
+ (childToParent && (input.punchPhase == "ready" || input.punchPhase == "complete")) {
+ return signalAuthorizationProbe
+ }
+ return signalAuthorizationDenied
+ }
+ if input.signalKind == "candidate" ||
(parentToChild && input.descriptionType == "offer") ||
- (childToParent && input.descriptionType == "answer")) {
- return signalAuthorizationProbe
+ (childToParent && input.descriptionType == "answer") {
+ return signalAuthorizationProbe
+ }
}
return signalAuthorizationDenied
}
}
+ // Preparation is never an active-edge capability or an unassigned signal.
+ if input.signalKind == "punch" || input.punched {
+ return signalAuthorizationDenied
+ }
childPeerID := childForPair(snapshot, input.sourcePeerID, input.targetPeerID)
if childPeerID == "" {
return signalAuthorizationUnassigned
@@ -723,6 +735,7 @@ func (r *router) handleRouteReady(participant authenticatedRouteParticipant, mes
"participant", r.debugPeer(participant.roomID, participant.peerID),
"revision", revision,
"accepted", settled.Accepted,
+ "committed", settled.Committed,
"exhausted", len(settled.FailedPeerIDs) > 0)
r.releaseResources(settled.Released)
if settled.Committed && settled.Accepted {
@@ -1089,7 +1102,7 @@ func (r *router) assignments(
publicationGeneration = snapshot.HostPublication.Generation
}
if candidate != nil && candidateChildPeerID != "" {
- // TS delete-then-set: the candidate child moves to the back.
+ // The replacement candidate follows the surviving committed children.
edges.Delete(candidateChildPeerID)
if candidate.Kind == route.UpstreamPeer {
edges.Set(candidateChildPeerID, assignmentEdge{kind: route.UpstreamPeer, parentPeerID: candidate.ParentPeerID})
@@ -1198,7 +1211,7 @@ func (r *router) broadcastActive(roomID string, rm *roomRuntime) {
r.debug(roomID, "active-topology",
"revision", snapshot.Revision,
"paused", snapshot.Paused,
- "hostPublication", snapshot.HostPublication != nil && snapshot.HostPublication.PhysicalActive,
+ "committedHostPublication", snapshot.HostPublication != nil && snapshot.HostPublication.PhysicalActive,
"routes", routes)
}
assignments := r.assignments(roomID, snapshot, nil, "", "")
@@ -1292,8 +1305,8 @@ type qualitySample struct {
hostSessionID string
}
-// qualityCopyContext is QualityCopyContext; operationReason and
-// candidateTransition are "" where the TS had null.
+// qualityCopyContext attributes copy demand to committed and candidate work;
+// operationReason and candidateTransition are empty when no such work exists.
type qualityCopyContext struct {
committedCopies int
candidateReservedCopies int
@@ -1519,8 +1532,7 @@ func nullable[T any](value protocol.Nullable[T]) any {
return *value.Value
}
-// nullString is the debug value of a string that is "" where the TS had
-// null or undefined.
+// nullString emits absent debug identities as null.
func nullString(value string) any {
if value == "" {
return nil
diff --git a/internal/server/signal/router_failure_test.go b/internal/server/signal/router_failure_test.go
index cc092abfd..000ce5705 100644
--- a/internal/server/signal/router_failure_test.go
+++ b/internal/server/signal/router_failure_test.go
@@ -4,8 +4,99 @@ import (
"testing"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
+ "github.com/TNTcraftHIM/Piik/internal/server/room"
+ "github.com/TNTcraftHIM/Piik/internal/server/sfu"
)
+func TestRouterPreparesSfuForNewAndReauthenticatedViewers(t *testing.T) {
+ for _, reauthenticate := range []bool{false, true} {
+ name := "new-viewer"
+ if reauthenticate {
+ name = "lost-retained-media"
+ }
+ t.Run(name, func(t *testing.T) {
+ h := newRouterHarness(t, routerHarnessOptions{capacity: 1, withSfu: true, fakeTimers: true})
+ created := h.createRoom()
+ _, first, sibling := h.establishSfuRoom(created)
+ viewer := first
+ if reauthenticate {
+ viewer.sessionID = "replacement_viewer_session_12345678"
+ h.locked(func() {
+ connected, err := h.store.ConnectParticipant(room.ConnectParticipantInput{
+ RoomID: created.RoomID, Role: protocol.RoleViewer,
+ ViewerGrant: created.ViewerGrant,
+ ClientID: "viewer_client_sfu-root-" + created.RoomID + "_12345678",
+ SessionID: viewer.sessionID,
+ })
+ if err != nil || connected.PeerID != first.peerID {
+ t.Fatalf("viewer replacement changed identity: %v", err)
+ }
+ })
+ } else {
+ viewer = h.connectViewer(created, "late-viewer")
+ }
+ var retained activeViewerMediaEdge
+ h.locked(func() {
+ if reauthenticate {
+ retained = h.doMustEdge(created.RoomID, viewer.peerID)
+ }
+ state := h.doComplete(viewer)
+ expected := protocol.NoUpstream()
+ if reauthenticate {
+ expected = protocol.SfuUpstream()
+ }
+ if state.routeAssignment.Upstream != expected {
+ t.Fatalf("authentication upstream = %v, want %v", state.routeAssignment.Upstream, expected)
+ }
+ })
+ if reauthenticate {
+ h.settle()
+ if sfuConfigCount(h.messages(viewer.sessionID)) != 1 {
+ t.Fatal("healthy retained media must receive its current configuration")
+ }
+ for _, message := range h.messages(viewer.sessionID) {
+ if _, ok := message.(protocol.RouteUpdatePrepareMessage); ok {
+ t.Fatal("reauthentication alone must not replace healthy media")
+ }
+ }
+ h.routeFailed(viewer, routeFailedMessage(retained.revision, "active", retained.connectionID))
+ }
+ prepared := h.waitPreparedTransport(viewer.sessionID, "sfu")
+ if prepared.Candidate.ConnectionID == retained.connectionID {
+ t.Fatal("replacement must allocate a new physical connection")
+ }
+ preparedSeen, configured := false, false
+ for _, message := range h.messages(viewer.sessionID) {
+ switch value := message.(type) {
+ case protocol.RouteUpdatePrepareMessage:
+ preparedSeen = value.Candidate.ConnectionID == prepared.Candidate.ConnectionID
+ case protocol.SfuConfigMessage:
+ if value.ConnectionID == prepared.Candidate.ConnectionID {
+ if !preparedSeen {
+ t.Fatal("new subscription configuration preceded its preparation")
+ }
+ configured = true
+ }
+ }
+ }
+ if !configured {
+ t.Fatal("prepared subscription has no configuration")
+ }
+ h.routeReady(viewer, int64(prepared.Revision))
+ h.locked(func() {
+ if h.doMustEdge(created.RoomID, sibling.peerID).upstream != protocol.SfuUpstream() {
+ t.Fatal("healthy sibling lost its SFU route")
+ }
+ })
+ egress := 3
+ if reauthenticate {
+ egress = 2
+ }
+ h.waitUsage(sfu.Usage{Ingress: 1, Egress: egress})
+ })
+ }
+}
+
func TestRouterFencesDelayedActiveFailureByPhysicalConnection(t *testing.T) {
for _, transport := range []string{"peer", "sfu"} {
for _, role := range []string{"host", "viewer"} {
diff --git a/internal/server/signal/router_pump.go b/internal/server/signal/router_pump.go
index 3b4b8c1e6..7a93ba413 100644
--- a/internal/server/signal/router_pump.go
+++ b/internal/server/signal/router_pump.go
@@ -12,7 +12,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/sfu"
)
-// preparedCandidate is PreparedCandidate; the optional TS keys are "" / nil.
+// preparedCandidate holds the allocations and signaling needed to begin an attempt.
type preparedCandidate struct {
reservation route.CandidateReservation
connectionID string
@@ -446,9 +446,8 @@ func (r *router) sendPrepareMessages(
}
}
-// scheduleDeadline registers a callback that verifies it is
-// still the registered deadline through the room's generation counter, which
-// clearDeadline bumps: a cleared TS timer never fired.
+// scheduleDeadline fences callbacks by the room's deadline generation.
+// clearDeadline invalidates even a callback that already won the timer Stop race.
func (r *router) scheduleDeadline(roomID string, rm *roomRuntime, operation *route.OperationSnapshot) {
r.clearDeadline(rm)
wakeInMs := max(int64(0), operation.WakeAtMs-r.now())
diff --git a/internal/server/signal/router_sfu_signal.go b/internal/server/signal/router_sfu_signal.go
index ceff44b73..b817be574 100644
--- a/internal/server/signal/router_sfu_signal.go
+++ b/internal/server/signal/router_sfu_signal.go
@@ -225,7 +225,7 @@ func (r *router) handleSfuMediaEvent(event sfu.MediaEvent) {
peer, connected := r.connectedPeer(event.Fence.RoomID, peerID)
snapshot := rm.controller.Snapshot()
participant := authenticatedRouteParticipant{roomID: event.Fence.RoomID, role: role, peerID: peerID, sessionID: peer.SessionID}
- _, revision := sfuConnectionPhase(snapshot, role, peerID, event.Fence.PublicationGeneration, event.ConnectionID)
+ phase, revision := sfuConnectionPhase(snapshot, role, peerID, event.Fence.PublicationGeneration, event.ConnectionID)
message := protocol.SfuSignalMessage{Type: "sfu-signal", Kind: "candidate", Revision: protocol.Int(revision), PublicationGeneration: event.Fence.PublicationGeneration, ConnectionID: event.ConnectionID}
if !connected && role == protocol.RoleHost && event.State == webrtc.PeerConnectionStateFailed {
publication := snapshot.HostPublication
@@ -245,11 +245,13 @@ func (r *router) handleSfuMediaEvent(event sfu.MediaEvent) {
r.hooks.sendToSession(participant.sessionID, message)
return
}
- if event.State == webrtc.PeerConnectionStateFailed {
- r.failSfuConnection(participant, message)
- return
- }
if event.State != webrtc.PeerConnectionStateUnknown {
+ r.debug(participant.roomID, "sfu-transport-state",
+ "participant", r.debugPeer(participant.roomID, participant.peerID),
+ "role", role, "phase", phase, "revision", revision, "state", event.State.String())
+ if event.State == webrtc.PeerConnectionStateFailed {
+ r.failSfuConnection(participant, message)
+ }
return
}
if event.Candidate != nil {
diff --git a/internal/server/signal/router_sfu_signal_test.go b/internal/server/signal/router_sfu_signal_test.go
index cfa4f6130..e5e92f4bc 100644
--- a/internal/server/signal/router_sfu_signal_test.go
+++ b/internal/server/signal/router_sfu_signal_test.go
@@ -66,6 +66,7 @@ func TestEmbeddedSfuSignalsRequireExactAdmittedConnectionAndCurrentSession(t *te
}
func TestEmbeddedSfuAuthenticatedWebSocketCarriesActualRTP(t *testing.T) {
+ diagnostics := captureRouteDebug(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
check := func(err error) {
@@ -209,6 +210,20 @@ func TestEmbeddedSfuAuthenticatedWebSocketCarriesActualRTP(t *testing.T) {
}
})
applyRemote(viewer, subscriber, viewerConfig, false)
+ // SFU reserves its audio track for later source/microphone changes. Match
+ // the product Viewer's receive preference even while this fixture is silent.
+ for _, transceiver := range subscriber.GetTransceivers() {
+ if transceiver.Kind() != webrtc.RTPCodecTypeAudio {
+ continue
+ }
+ for _, codec := range transceiver.Receiver().GetParameters().Codecs {
+ if codec.MimeType == webrtc.MimeTypeOpus {
+ codec.SDPFmtpLine = "stereo=1;maxaveragebitrate=510000"
+ check(transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec}))
+ break
+ }
+ }
+ }
answer, err := subscriber.CreateAnswer(nil)
check(err)
sendDescription(viewer, viewerConfig, gather(subscriber, answer), nil)
@@ -216,11 +231,23 @@ func TestEmbeddedSfuAuthenticatedWebSocketCarriesActualRTP(t *testing.T) {
if !waitFor(t, 3*time.Second, func() bool { return received.Load() >= 3 }) {
t.Fatal("authenticated signaling did not produce forwarded video RTP")
}
+ if !waitFor(t, time.Second, func() bool {
+ return diagnostics.calledWith(h.room.RoomID, "sfu-transport-state", map[string]any{
+ "role": "host", "phase": "prepare", "revision": int64(hostConfig.Revision), "state": "connected",
+ }) && diagnostics.calledWith(h.room.RoomID, "sfu-transport-state", map[string]any{
+ "role": "viewer", "phase": "prepare", "revision": int64(viewerConfig.Revision), "state": "connected",
+ })
+ }) {
+ t.Fatal("SFU diagnostics did not distinguish connected ingress and egress before route commit")
+ }
h.locked(func() {
room, _ := h.server.router.rooms.Get(h.room.RoomID)
if room.controller.Operation() == nil {
t.Fatal("transport/RTP arrival committed without client decoded-frame proof")
}
+ if room.controller.Publication() != nil {
+ t.Fatal("preparing media was advertised as a committed Host publication")
+ }
})
viewer.sendJSON(map[string]any{"type": "route-ready", "revision": viewerConfig.Revision, "phase": "prepare"})
if !waitFor(t, time.Second, func() bool {
diff --git a/internal/server/signal/server.go b/internal/server/signal/server.go
index 38cdaba03..34803f5a0 100644
--- a/internal/server/signal/server.go
+++ b/internal/server/signal/server.go
@@ -6,10 +6,11 @@
//
// Server.mu is the one lock of the effect layer: it guards every field of
// the server and the router, the room.Store and the route controllers. Every
-// handler, timer callback and goroutine takes it; it is released only around
-// I/O (the password KDF and media transport), after which current session,
+// handler, timer callback and goroutine takes it; password KDF and media
+// transport work run unlocked, after which current session,
// room and operation identities must be revalidated. Router methods and hooks are
-// called with mu held and never lock.
+// called with mu held and never lock. Room persistence and the optional route
+// diagnostic sink are synchronous and belong in this lock's latency budget.
package signal
import (
@@ -35,19 +36,17 @@ import (
// Connection admission, queue and timeout bounds limit the signaling resources
// a stalled or unauthenticated peer can retain.
const (
- // maxBufferedSignalBytes is MAX_BUFFERED_SIGNAL_BYTES: once a connection's
- // unsent queue passes it the peer is not draining and the connection is
- // terminated, so one stalled reader cannot grow the server's heap.
+ // maxBufferedSignalBytes bounds unsent data per connection; exceeding it
+ // terminates a stalled reader before it can grow the server's heap.
maxBufferedSignalBytes = 256 * 1024
// defaultMaxSignalConnections and defaultMaxUnauthenticatedConnections are
- // the DEFAULT_MAX_* admission ceilings: 20 viewers plus a host across 9_000
+ // admission ceilings: 20 viewers plus a host across 9_000
// room codes is far above either, so they bound abuse, not capacity.
defaultMaxSignalConnections = 2_048
defaultMaxUnauthenticatedConnections = 256
- // minSignalingChallengeIntervalMs is MIN_SIGNALING_CHALLENGE_INTERVAL_MS:
- // the floor between two password challenges on one connection.
+ // minSignalingChallengeIntervalMs bounds liveness challenge frequency.
minSignalingChallengeIntervalMs = int64(1_000)
- // serviceRestartCloseGrace is SERVICE_RESTART_CLOSE_GRACE_MS: how long a
+ // serviceRestartCloseGrace is how long a
// peer has to acknowledge close{1012} before the socket is terminated.
serviceRestartCloseGrace = 1_000 * time.Millisecond
@@ -96,12 +95,12 @@ type Options struct {
MaxUnauthenticatedConnections int
// AfterFunc is the timer factory; nil uses time.AfterFunc.
AfterFunc func(time.Duration, func()) func() bool
- // Logger receives the three console.error records; nil uses slog.Default().
+ // Logger receives signaling failures; nil uses slog.Default().
Logger *slog.Logger
}
-// viewerQualityEvidenceGate is ViewerQualityEvidenceGate; upstreamPeerID is
-// "" where the TS had null.
+// viewerQualityEvidenceGate rejects stale or repeated evidence for the current
+// edge and presentation epoch. upstreamPeerID is empty for SFU.
type viewerQualityEvidenceGate struct {
viewerSessionID string
upstreamKind string
@@ -111,32 +110,16 @@ type viewerQualityEvidenceGate struct {
sequence int64
}
-// viewerQualityEvidenceAttempt is viewerQualityEvidenceAttemptAtMs plus the
-// committed-edge connection the stamp was taken under. Tagging replaces the
-// TS setViewerConnectionId reset: a viewer whose media identity changed is
-// never held back by the previous route's stamp.
-//
-// Deviation: it does not replace the deleteViewerConnectionId reset, which
-// broadcastActive also ran when a viewer's path stopped being physical. A
-// viewer whose path breaks and heals on the same committed connection without
-// sending evidence in between keeps its stamp here, where the TypeScript had
-// cleared it, so one frame inside the 2 s window can be dropped that the
-// TypeScript accepted. Unobservable: the reporter's own
-// VIEWER_QUALITY_EVIDENCE_INTERVAL_MS gate means its next send is already at
-// least that far from the last accepted one. The epoch/sequence gate below
-// cannot be affected the same way, because a committed edge that loses
-// physicalActive is never reactivated - its replacement carries a new
-// connectionID, so sameEdge is false.
+// viewerQualityEvidenceAttempt scopes the rate window to a committed connection.
+// A replacement connection is not held back by its predecessor's timestamp.
+// Reports for the same connection remain subject to the existing window.
type viewerQualityEvidenceAttempt struct {
connectionID string
atMs int64
}
-// roomShare is one Host share of one room. Its zero value is the TS state
-// where none of the four per-room share maps had an entry, so reads need no
-// presence check: generation and pausedGeneration are never stored empty
-// (authenticate falls back to the session id), which is what made the TS
-// `undefined === undefined` comparisons meaningful.
+// roomShare owns one room's Host publication intent. Its zero value means no
+// intent; active generations are nonempty, falling back to the Host session ID.
type roomShare struct {
// generation survives stopSharing; only closeRoom drops the whole
// record.
@@ -149,19 +132,18 @@ type roomShare struct {
pausedGeneration string
}
-// senderQualityRateWindow is SenderQualityRateWindow.
type senderQualityRateWindow struct {
startedAtMs int64
count int
}
-// graceTimer is one viewerGraceTimers entry; the callback compares the
-// registered pointer with its own (a cleared TS timer never fired).
+// graceTimer callbacks compare their registered pointer so replacement or
+// cancellation invalidates a callback even after its timer has fired.
type graceTimer struct {
stop func() bool
}
-// Server is SignalingServer.
+// Server owns room signaling and serializes its effects.
type Server struct {
// ponytail: global lock, per-room locks if throughput matters
mu sync.Mutex
@@ -199,9 +181,7 @@ type Server struct {
viewerQualityEvidenceGates map[string]viewerQualityEvidenceGate
viewerQualityEvidenceAttempts map[string]viewerQualityEvidenceAttempt
senderQualityRateBySession map[string]*senderQualityRateWindow
- // shares owns the whole per-room share lifecycle (TS
- // shareGenerationsByRoom, qualitySettingsByRoom, routePolicyByRoom and
- // pausedShareGenerationsByRoom).
+ // shares keeps generation, settings, route policy and pause intent together.
shares map[string]roomShare
deferredViewerPresenceRooms map[string]struct{}
@@ -209,8 +189,7 @@ type Server struct {
closing bool
}
-// New is the SignalingServer constructor. It performs no I/O; the heartbeat
-// ticker starts immediately.
+// New creates the signaling owner and starts its heartbeat timer.
func New(options Options) (*Server, error) {
if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil {
return nil, err
@@ -317,9 +296,8 @@ func orDefault(value, fallback int) int {
return value
}
-// InviteURL is viewerInviteUrl / the inviteUrl of createRoomResponse:
-// new URL(`/r/${roomId}`, publicBaseUrl) with the Viewer grant in the
-// fragment only. viewerGrant "" is the TypeScript null.
+// InviteURL adds an optional Viewer grant only in the URL fragment, keeping it
+// out of HTTP requests. Existing base URL query/fragment values are discarded.
func InviteURL(publicBaseURL *url.URL, roomID, viewerGrant string) string {
invite := *publicBaseURL
invite.Opaque = ""
@@ -339,20 +317,16 @@ func InviteURL(publicBaseURL *url.URL, roomID, viewerGrant string) string {
// upgrade
// ---------------------------------------------------------------------------
-// ServeHTTP is the "upgrade" listener: the TS rejection ladder, then the
-// accept.
+// ServeHTTP validates the signaling endpoint, origin and capacity before upgrade.
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
- // accepts a scheme-relative target such as "//[" as a plain path, so
- // the WHATWG authority parse is re-run on the raw target.
+ // HTTP parsing accepts scheme-relative targets as paths; reject malformed
+ // authorities such as "//[" before checking the endpoint.
if _, err := url.Parse(request.RequestURI); err != nil {
rejectUpgrade(writer, http.StatusBadRequest)
return
}
- // EscapedPath, not Path: the WHATWG pathname the TS compared is not
- // percent-decoded, so "/%73ignal" was a 404 there, while URL.Path decodes
- // it to "/signal" and would give the endpoint an alias the TS never had.
- // This is the path notion the sibling HTTP router already uses
- // (app/json.go requestPath), with the same dot-segment deviation.
+ // Match the literal endpoint, rejecting encoded aliases such as "/%73ignal",
+ // just as the sibling HTTP router's requestPath does.
if request.URL.EscapedPath() != "/signal" || request.URL.RawQuery != "" {
rejectUpgrade(writer, http.StatusNotFound)
return
@@ -373,15 +347,14 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
rejectUpgrade(writer, http.StatusServiceUnavailable)
return
}
- // The TS counted the slot synchronously inside handleUpgrade; Accept
- // performs I/O, so the slot is reserved across it.
+ // Reserve capacity before Accept performs I/O outside the lock.
s.pendingConnections++
s.mu.Unlock()
siteAccessAuthenticated := s.siteAccessAtUpgrade(request)
var sess *session
// Origin was verified above with the exact isAllowedOrigin rule, so the
- // library's own check is skipped; the TS ws server never compressed.
+ // library's own check is skipped.
conn, err := websocket.Accept(writer, request, &websocket.AcceptOptions{
InsecureSkipVerify: true,
CompressionMode: websocket.CompressionDisabled,
@@ -502,6 +475,14 @@ func (s *Server) Close(ctx context.Context) error {
return routeCloseError
}
+// CloseStorage follows HTTP draining. A timed-out drain can leave admitted
+// mutations running, so database closure still takes the store's owning lock.
+func (s *Server) CloseStorage() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.store.Close()
+}
+
// EndAllRooms closes rooms in store order.
func (s *Server) EndAllRooms() error {
s.mu.Lock()
@@ -571,10 +552,9 @@ func (s *Server) UpdateRoomAccess(
return nil, fmt.Errorf("unknown room access request %T", request)
}
-// deriveViewerPasswordMaterial is the unlocked KDF window of replaceRoom and
-// setViewerPassword; mayStart is the TS `rooms.get(roomId) === current &&
-// verifyDigest(hostToken)` closure. mu must be held; it is released only
-// while the derivation runs. A nil password derives nothing.
+// deriveViewerPasswordMaterial releases mu for password derivation, rechecking
+// exact room/Host authority when the gate admits the work. The caller holds mu;
+// a nil or empty password derives nothing.
func (s *Server) deriveViewerPasswordMaterial(
roomID, hostToken string, password *string, current *room.Room,
) (material []byte, derived error) {
@@ -618,8 +598,8 @@ func (s *Server) ReplaceRoom(
return replacement.Created, nil
}
-// CreateRoom is the roomStore.createRoom call of app.ts, run under mu with
-// the KDF unlocked (the room package's documented sequence).
+// CreateRoom serializes room admission and commit, releasing mu for password
+// derivation according to the room package's locking contract.
func (s *Server) CreateRoom(
codeEntryPolicy protocol.CodeEntryPolicy,
roomPassword *string,
@@ -727,51 +707,48 @@ func (s *Server) handleMessage(sess *session, encoded []byte) {
// fields authenticate() and authenticationErrorCode() read. Empty strings
// and nil pointers are absent keys.
type authRequest struct {
- role protocol.Role
- roomID string
- clientID string
- token string
- viewerGrant string
- viewerPassword string
- shareGeneration string
- sharingPaused bool
- qualitySettings *protocol.QualitySettings
- routePolicy protocol.RoutePolicy
- viewerPresence bool
- connectionAttemptProgress4 bool
- displayName *string
- roomOnly bool
- roomSession bool
+ role protocol.Role
+ roomID string
+ clientID string
+ token string
+ viewerGrant string
+ viewerPassword string
+ shareGeneration string
+ sharingPaused bool
+ qualitySettings *protocol.QualitySettings
+ routePolicy protocol.RoutePolicy
+ viewerPresence bool
+ displayName *string
+ roomOnly bool
+ roomSession bool
}
func authRequestOf(message protocol.ClientMessage) (authRequest, bool) {
switch m := message.(type) {
case protocol.AuthenticateHostMessage:
return authRequest{
- role: protocol.RoleHost,
- roomID: m.RoomID,
- clientID: m.ClientID,
- token: m.Token,
- shareGeneration: m.ShareGeneration,
- sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
- qualitySettings: m.QualitySettings,
- routePolicy: m.RoutePolicy,
- viewerPresence: m.ViewerPresence,
- connectionAttemptProgress4: m.ConnectionAttemptProgress4,
- displayName: displayNameString(m.DisplayName),
- roomOnly: m.RoomOnly,
- roomSession: m.RoomSession,
+ role: protocol.RoleHost,
+ roomID: m.RoomID,
+ clientID: m.ClientID,
+ token: m.Token,
+ shareGeneration: m.ShareGeneration,
+ sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
+ qualitySettings: m.QualitySettings,
+ routePolicy: m.RoutePolicy,
+ viewerPresence: m.ViewerPresence,
+ displayName: displayNameString(m.DisplayName),
+ roomOnly: m.RoomOnly,
+ roomSession: m.RoomSession,
}, true
case protocol.AuthenticateViewerMessage:
return authRequest{
- role: protocol.RoleViewer,
- roomID: m.RoomID,
- clientID: m.ClientID,
- viewerGrant: m.ViewerGrant,
- viewerPassword: m.ViewerPassword,
- viewerPresence: m.ViewerPresence,
- connectionAttemptProgress4: m.ConnectionAttemptProgress4,
- displayName: displayNameString(m.DisplayName),
+ role: protocol.RoleViewer,
+ roomID: m.RoomID,
+ clientID: m.ClientID,
+ viewerGrant: m.ViewerGrant,
+ viewerPassword: m.ViewerPassword,
+ viewerPresence: m.ViewerPresence,
+ displayName: displayNameString(m.DisplayName),
}, true
}
return authRequest{}, false
@@ -906,7 +883,7 @@ func (s *Server) authenticationFailed(sess *session, request authRequest, err er
func (s *Server) completeAuthentication(sess *session, request authRequest, participant room.ConnectedParticipant) {
if s.closing || !s.sessions.Has(sess) || sess.revoked || !sess.open() {
if _, err := s.store.DisconnectParticipant(participant.RoomID, participant.PeerID, sess.sessionID); err != nil {
- // The TS threw out of the async function; there is no catcher.
+ // Failing to retire the aborted admission is an internal invariant failure.
panic(fmt.Errorf("room store failed while releasing an aborted authentication: %w", err))
}
return
@@ -921,14 +898,13 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
}
displayName := authenticatedDisplayName(request)
authenticated := &authenticatedSession{
- roomID: roomID,
- role: participant.Role,
- peerID: participant.PeerID,
- shareGeneration: shareGeneration,
- displayName: displayName,
- viewerPresence: request.viewerPresence,
- connectionAttemptProgress4: request.connectionAttemptProgress4,
- roomSession: request.roomSession,
+ roomID: roomID,
+ role: participant.Role,
+ peerID: participant.PeerID,
+ shareGeneration: shareGeneration,
+ displayName: displayName,
+ viewerPresence: request.viewerPresence,
+ roomSession: request.roomSession,
}
sess.authenticated = authenticated
if participant.Role == protocol.RoleViewer {
@@ -1092,8 +1068,7 @@ func (s *Server) settleHostShare(roomID, sessionID string, request authRequest)
if currentGeneration != "" && currentGeneration != shareGeneration {
s.stopSharing(roomID)
}
- // stopSharing may have cleared the record, so the survivors are read
- // after it, exactly as the TS map reads were.
+ // stopSharing may have cleared fields, so read the surviving intent afterwards.
share := s.shares[roomID]
if request.shareGeneration != "" &&
(currentGeneration != shareGeneration || share.qualitySettings == nil) {
@@ -1149,9 +1124,8 @@ func authenticatedDisplayName(request authRequest) *string {
return &name
}
-// connectRouteParticipant is the try/catch around
-// hybridMediaRouter.connectParticipant: a controller assertion panics with
-// the TS message, which the TS catch swallowed.
+// connectRouteParticipant leaves the participant unassigned if a controller
+// assertion prevents route admission.
func (s *Server) connectRouteParticipant(participant authenticatedRouteParticipant) (state hybridAuthenticationState, assigned bool) {
defer func() {
if recover() != nil {
@@ -1470,7 +1444,7 @@ func (s *Server) handleViewerQualityEvidence(sess *session, source *authenticate
}
// The committed edge owns the viewer's media identity; connectionID is
- // "" while the viewer has no physical path (the TS mirror was absent).
+ // empty while the viewer has no physical path.
edge, hasEdge := s.router.resolveActiveViewerMediaEdge(source.roomID, source.peerID)
connectionID := ""
if hasEdge {
@@ -1652,6 +1626,10 @@ func (s *Server) routePeerAssistedSignal(sess *session, source *authenticatedSes
if payload.Kind == "description" && payload.Description != nil {
descriptionType = payload.Description.Type
}
+ punchPhase := ""
+ if payload.Punch != nil {
+ punchPhase = payload.Punch.Phase
+ }
candidateAuthorized := s.router.peerSignalAuthorization(peerSignalInput{
roomID: source.roomID,
sourcePeerID: source.peerID,
@@ -1661,6 +1639,8 @@ func (s *Server) routePeerAssistedSignal(sess *session, source *authenticatedSes
connectionID: payload.ConnectionID,
signalKind: payload.Kind,
descriptionType: descriptionType,
+ punchPhase: punchPhase,
+ punched: payload.Punched,
})
debugInput := peerSignalDebugInput{
roomID: source.roomID,
@@ -1700,8 +1680,7 @@ func (s *Server) routePeerAssistedSignal(sess *session, source *authenticatedSes
return
}
- // TS setViewerConnectionId here only ever restated the committed edge:
- // an authorized non-probe offer either matches edge.connectionId or was
+ // An authorized non-probe offer either matches the committed connection or was
// adopted into it by peerSignalAuthorization.
s.sendToSession(target.SessionID, protocol.ServerSignalMessage{
Type: "signal",
diff --git a/internal/server/signal/server_test.go b/internal/server/signal/server_test.go
index 79db2d308..388a8dc1d 100644
--- a/internal/server/signal/server_test.go
+++ b/internal/server/signal/server_test.go
@@ -72,6 +72,71 @@ func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
}
}
+func TestPasswordPreparationDoesNotBlockAnotherRoom(t *testing.T) {
+ for _, update := range []bool{false, true} {
+ t.Run(fmt.Sprintf("update=%v", update), func(t *testing.T) {
+ started, release := make(chan struct{}), make(chan struct{})
+ var hold atomic.Bool
+ var releaseOnce sync.Once
+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
+ t.Cleanup(unblock)
+ store, err := room.New(room.Options{
+ MaxRooms: 3, MaxViewersPerRoom: 1,
+ Random: func(size int) []byte {
+ // Hold the existing salt source in the unlocked password phase.
+ if hold.CompareAndSwap(true, false) {
+ close(started)
+ <-release
+ }
+ return bytes.Repeat([]byte{byte(size)}, size)
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store}
+ original, err := server.CreateRoom(protocol.CodeEntryOpen, nil, "1234")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hold.Store(true)
+ done := make(chan error, 1)
+ go func() {
+ password := "room-password"
+ var err error
+ if update {
+ _, err = server.UpdateRoomAccess(original.RoomID, original.HostToken,
+ protocol.SetViewerPasswordRequest{Password: &password})
+ } else {
+ _, err = server.CreateRoom(protocol.CodeEntryPrivate, &password, "2345")
+ }
+ done <- err
+ }()
+ select {
+ case <-started:
+ case <-time.After(defaultWait):
+ t.Fatal("password preparation did not start")
+ }
+ if !server.mu.TryLock() {
+ t.Fatal("password work blocks signaling for other rooms")
+ }
+ server.mu.Unlock()
+ if _, err := server.CreateRoom(protocol.CodeEntryOpen, nil, "3456"); err != nil {
+ t.Fatalf("independent room creation: %v", err)
+ }
+ unblock()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("password operation: %v", err)
+ }
+ case <-time.After(defaultWait):
+ t.Fatal("password operation did not finish")
+ }
+ })
+ }
+}
+
func TestStopSharingDoesNotWriteRoomAuthority(t *testing.T) {
database, err := room.NewDatabase(filepath.Join(t.TempDir(), "rooms.sqlite"))
if err != nil {
@@ -946,16 +1011,15 @@ func isMediaReady(entry presenceEntry) bool {
// ---------------------------------------------------------------------------
type presenceOptions struct {
- connectionAttemptProgress4 bool
- displayName string
- viewerPresence bool
- viewerPassword string
- codeOnly bool
- sharingPaused *bool
- qualitySettings map[string]any
- routePolicy *protocol.RoutePolicy
- roomSession bool
- roomOnly bool
+ displayName string
+ viewerPresence bool
+ viewerPassword string
+ codeOnly bool
+ sharingPaused *bool
+ qualitySettings map[string]any
+ routePolicy *protocol.RoutePolicy
+ roomSession bool
+ roomOnly bool
}
// authenticate ports the authenticate() helper. relayCapacity -1 is the TS
@@ -978,9 +1042,6 @@ func authenticate(
"role": role,
"clientId": clientID,
}
- if presence.connectionAttemptProgress4 {
- message["connectionAttemptProgress4"] = true
- }
if role == protocol.RoleHost {
message["token"] = rm.HostToken
if shareGeneration != "" {
@@ -1311,51 +1372,31 @@ func TestSignalDeliversOnlySelfHostedAuxiliaryNatStunEndpoints(t *testing.T) {
// lifecycle and restart
// ---------------------------------------------------------------------------
-func TestConnectionAttemptProgressIsNegotiatedForEachRecipient(t *testing.T) {
- for _, hostSupport := range []bool{false, true} {
- for _, viewerSupport := range []bool{false, true} {
- t.Run(fmt.Sprintf("host=%t/viewer=%t", hostSupport, viewerSupport), func(t *testing.T) {
- h := startHarness(t, harnessOptions{
- natPredictionEnabled: true,
- stunURLs: []string{"stun:share.example.test:3478"},
- })
- host, viewer := openClient(t, h), openClient(t, h)
- policy := protocol.RoutePolicy{PeerOnly: true, NatPrediction: true}
- authenticate(t, host, h.room, protocol.RoleHost, "progress-host", 1, "progress_share_generation_12345678",
- presenceOptions{routePolicy: &policy, connectionAttemptProgress4: hostSupport})
- authenticate(t, viewer, h.room, protocol.RoleViewer, "progress-viewer", 0, "",
- presenceOptions{connectionAttemptProgress4: viewerSupport})
- for current := 1; current <= 4; current++ {
- hostPrepare, viewerPrepare := nextPreparedRoute(t, host), nextPreparedRoute(t, viewer)
- for _, recipient := range []struct {
- update routeUpdate
- support bool
- }{
- {hostPrepare, hostSupport}, {viewerPrepare, viewerSupport},
- } {
- decoded, err := protocol.DecodeServerMessage(recipient.update.raw)
- if err != nil {
- t.Fatal(err)
- }
- progress := decoded.(protocol.RouteUpdatePrepareMessage).Candidate.ConnectionAttempt
- if recipient.support {
- if progress == nil || *progress != (protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}) {
- t.Fatalf("new reader progress = %+v", progress)
- }
- } else if progress != nil {
- t.Fatalf("old reader received unsupported progress: %+v", progress)
- }
- }
- if hostPrepare.Candidate.ConnectionID != viewerPrepare.Candidate.ConnectionID {
- t.Fatal("reader capability changed the actual candidate")
- }
- viewer.sendJSON(map[string]any{
- "type": "route-failed", "phase": "prepare",
- "revision": viewerPrepare.Revision, "connectionId": viewerPrepare.Candidate.ConnectionID,
- })
- }
- })
+func TestConnectionAttemptProgressMatchesBothRecipients(t *testing.T) {
+ h := startHarness(t, harnessOptions{natPredictionEnabled: true,
+ stunURLs: []string{"stun:share.example.test:3478"}})
+ host, viewer := openClient(t, h), openClient(t, h)
+ policy := protocol.RoutePolicy{PeerOnly: true, NatPrediction: true}
+ authenticate(t, host, h.room, protocol.RoleHost, "progress-host", 1, "progress_share_generation_12345678",
+ presenceOptions{routePolicy: &policy})
+ authenticate(t, viewer, h.room, protocol.RoleViewer, "progress-viewer", 0, "", presenceOptions{})
+ for current := 1; current <= 4; current++ {
+ hostPrepare, viewerPrepare := nextPreparedRoute(t, host), nextPreparedRoute(t, viewer)
+ for _, update := range []routeUpdate{hostPrepare, viewerPrepare} {
+ decoded, err := protocol.DecodeServerMessage(update.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ progress := decoded.(protocol.RouteUpdatePrepareMessage).Candidate.ConnectionAttempt
+ if progress == nil || *progress != (protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}) {
+ t.Fatalf("progress = %+v", progress)
+ }
+ }
+ if hostPrepare.Candidate.ConnectionID != viewerPrepare.Candidate.ConnectionID {
+ t.Fatal("recipients disagree on the candidate")
}
+ viewer.sendJSON(map[string]any{"type": "route-failed", "phase": "prepare",
+ "revision": viewerPrepare.Revision, "connectionId": viewerPrepare.Candidate.ConnectionID})
}
}
@@ -2365,13 +2406,13 @@ func TestSignalRetainsAuthoritativePauseWhenReconnectingHostAdvertisesUnpaused(t
func TestSignalBindsNewShareQualityAndPreservesActiveShareQualityAcrossReconnect(t *testing.T) {
h := startHarness(t, harnessOptions{})
initialSettings := map[string]any{
- "resolution": "1440p",
+ "resolution": protocol.QualityResolution{Width: 2560, Height: 1440},
"maxFramerate": 60,
"maxBitrate": 8_000_000,
"degradationPreference": "maintain-resolution",
- "screenAudioQuality": "very-high",
+ "maxAudioBitrate": 192000,
}
- initialJSON := `{"resolution":"1440p","maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","screenAudioQuality":"very-high"}`
+ initialJSON := `{"resolution":{"width":2560,"height":1440},"maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","maxAudioBitrate":192000}`
firstGeneration := "first_share_generation_12345678"
viewer := openClient(t, h)
authenticate(t, viewer, h.room, protocol.RoleViewer, "quality-viewer-client", 1, "", presenceOptions{})
@@ -2383,13 +2424,13 @@ func TestSignalBindsNewShareQualityAndPreservesActiveShareQualityAcrossReconnect
expectEqual(t, viewer.next("quality-settings").raw, `{"type":"quality-settings","qualitySettings":`+initialJSON+`}`)
reconnectSettings := map[string]any{
- "resolution": "480p",
+ "resolution": protocol.QualityResolution{Width: 854, Height: 480},
"maxFramerate": 15,
"maxBitrate": 2_000_000,
"degradationPreference": "maintain-framerate",
- "screenAudioQuality": "saver",
+ "maxAudioBitrate": 64000,
}
- reconnectJSON := `{"resolution":"480p","maxFramerate":15,"maxBitrate":2000000,"degradationPreference":"maintain-framerate","screenAudioQuality":"saver"}`
+ reconnectJSON := `{"resolution":{"width":854,"height":480},"maxFramerate":15,"maxBitrate":2000000,"degradationPreference":"maintain-framerate","maxAudioBitrate":64000}`
reconnectedHost := openClient(t, h)
reconnectedAuth := authenticate(t, reconnectedHost, h.room, protocol.RoleHost, "quality-host-client", 1, firstGeneration,
presenceOptions{qualitySettings: reconnectSettings})
@@ -2400,13 +2441,13 @@ func TestSignalBindsNewShareQualityAndPreservesActiveShareQualityAcrossReconnect
}
updatedSettings := map[string]any{
- "resolution": "720p",
+ "resolution": protocol.QualityResolution{Width: 1280, Height: 720},
"maxFramerate": 30,
"maxBitrate": 3_000_000,
"degradationPreference": "balanced",
- "screenAudioQuality": "music",
+ "maxAudioBitrate": 128000,
}
- updatedJSON := `{"resolution":"720p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","screenAudioQuality":"music"}`
+ updatedJSON := `{"resolution":{"width":1280,"height":720},"maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","maxAudioBitrate":128000}`
reconnectedHost.sendJSON(map[string]any{"type": "set-quality-settings", "qualitySettings": updatedSettings})
expectEqual(t, viewer.next("quality-settings").raw, `{"type":"quality-settings","qualitySettings":`+updatedJSON+`}`)
diff --git a/internal/server/signal/session.go b/internal/server/signal/session.go
index e6c169ce8..1d9f2f011 100644
--- a/internal/server/signal/session.go
+++ b/internal/server/signal/session.go
@@ -16,14 +16,13 @@ import (
// authentication replaced between dispatch and use. Empty shareGeneration
// means no share; nil displayName means the participant has no display name.
type authenticatedSession struct {
- roomID string
- role protocol.Role
- peerID string
- shareGeneration string
- displayName *string
- viewerPresence bool
- connectionAttemptProgress4 bool
- roomSession bool
+ roomID string
+ role protocol.Role
+ peerID string
+ shareGeneration string
+ displayName *string
+ viewerPresence bool
+ roomSession bool
}
// outbound is one queue item: a text frame or the close frame that ends the
@@ -35,14 +34,13 @@ type outbound struct {
reason string
}
-// session is SocketState plus the connection it belongs to. Every field is
+// session owns one signaling connection and its admission state. Every field is
// guarded by Server.mu except conn, ctx, cancel, wake and readerDone, which
// are immutable after accept.
type session struct {
conn *websocket.Conn
// ctx is cancelled by terminate(); coder/websocket closes the socket
- // when the context of a pending Read or Write expires, which is the
- // `socket.terminate()` of the TS.
+ // when the context of a pending Read or Write expires.
ctx context.Context
cancel context.CancelFunc
@@ -51,7 +49,7 @@ type session struct {
siteAccessAuthenticated bool
revoked bool
authenticating bool
- // challenged is `lastSignalingChallengeAtMs !== undefined`.
+ // challenged distinguishes no challenge yet from a timestamp of zero.
challenged bool
lastSignalingChallengeAtMs int64
authenticated *authenticatedSession
@@ -59,17 +57,17 @@ type session struct {
nextInteractionAtMs int64
// authStop stops the authentication deadline; authGeneration is
- // bumped whenever the TS cleared the timer so a callback that lost the
+ // bumped whenever the timer is cleared so a callback that lost the
// Stop race sees it is stale.
authStop func() bool
authGeneration uint64
- // queue is the ws send buffer; queuedBytes is its bufferedAmount.
+ // queuedBytes accounts for pending outbound text frames.
queue []outbound
queuedBytes int
wake chan struct{}
- // closeQueued is readyState !== OPEN once close() was called: later
- // sends are dropped and later closes are no-ops, as with ws.
+ // closeQueued stops further sends and repeated close requests while the
+ // writer completes the existing queue and close handshake.
closeQueued bool
terminated bool
readerDone chan struct{}
@@ -89,7 +87,7 @@ func newSession(conn *websocket.Conn, siteAccessAuthenticated bool) *session {
}
}
-// open is `socket.readyState === WebSocket.OPEN`.
+// open reports whether this session still accepts outgoing messages.
func (sess *session) open() bool {
return !sess.closeQueued && !sess.terminated
}
@@ -108,7 +106,7 @@ func (sess *session) enqueue(encoded []byte) {
sess.notify()
}
-// close is socket.close(code, reason): a no-op unless the socket is open.
+// close queues a close handshake only while the socket is open.
// mu must be held.
func (sess *session) close(code websocket.StatusCode, reason string) {
if !sess.open() {
@@ -119,7 +117,7 @@ func (sess *session) close(code websocket.StatusCode, reason string) {
sess.notify()
}
-// terminate is socket.terminate(): abrupt, no close frame. Cancelling the
+// terminate closes abruptly without a close frame. Cancelling the
// context closes the socket from inside the library, so nothing touches the
// connection under mu. mu must be held.
func (sess *session) terminate() {
@@ -130,7 +128,7 @@ func (sess *session) terminate() {
sess.cancel()
}
-// clearAuthenticationTimer is clearTimeout(state.authenticationTimer).
+// clearAuthenticationTimer invalidates pending and already-fired callbacks.
func (sess *session) clearAuthenticationTimer() {
if sess.authStop != nil {
sess.authStop()
@@ -155,8 +153,8 @@ func (s *Server) accept(conn *websocket.Conn, siteAccessAuthenticated bool) *ses
return sess
}
-// armAuthenticationTimer is the setTimeout of accept(). The TS timer
-// fired blind; the generation check stands in for clearTimeout.
+// armAuthenticationTimer bounds admission time. The generation check rejects
+// a stale callback even when stopping the timer lost the race.
func (s *Server) armAuthenticationTimer(sess *session) {
generation := sess.authGeneration
sess.authStop = s.afterFunc(time.Duration(s.authenticationTimeoutMs)*time.Millisecond, func() {
@@ -261,13 +259,6 @@ func (s *Server) heartbeat() []*session {
// send drops unless open, terminates above the buffer bound,
// and otherwise queues the encoded message.
func (s *Server) send(sess *session, message protocol.ServerMessage) {
- // Progress is optional feedback, not route authority. Older pages reject
- // totals above three; omit it for that recipient without changing the route.
- if prepare, ok := message.(protocol.RouteUpdatePrepareMessage); ok &&
- (sess.authenticated == nil || !sess.authenticated.connectionAttemptProgress4) {
- prepare.Candidate.ConnectionAttempt = nil
- message = prepare
- }
encoded, err := protocol.EncodeServerMessage(message)
if err != nil {
// JSON.stringify of a server literal cannot fail; neither can this.
diff --git a/internal/server/stun/server_test.go b/internal/server/stun/server_test.go
index 958f644ad..0c620bc69 100644
--- a/internal/server/stun/server_test.go
+++ b/internal/server/stun/server_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "github.com/pion/stun/v3"
+ "github.com/pion/stun/v4"
)
func TestThreeSTUNListenersWithoutRelayAndWithCompleteShutdown(t *testing.T) {
diff --git a/internal/thirdparty/README.md b/internal/thirdparty/README.md
index 1352b9115..28778519c 100644
--- a/internal/thirdparty/README.md
+++ b/internal/thirdparty/README.md
@@ -24,3 +24,43 @@ pass, replace both local module directives with version pins, remove their sourc
directories and the extra dependency test patterns in `scripts/check-go.mjs`.
Until then, review updates against the named baselines; do not edit Go's shared
module cache or apply build-time patches.
+
+## LiveKit Send-Side Bandwidth Estimation
+
+`livekit/sendsidebwe` retains only the eight source files from
+[LiveKit v1.13.6's package](https://github.com/livekit/livekit/tree/v1.13.6/pkg/sfu/bwe/sendsidebwe),
+with its original `LICENSE` and `NOTICE`. It uses the existing pinned LiveKit
+interfaces; the rest of LiveKit remains the normal Go dependency. There is no
+whole-module fork or build-time patch.
+
+Two measurement corrections are marked in the modified files: probe finalization
+returns the current acknowledged capacity and rejects invalid/empty feedback;
+probe timing begins with its first acknowledged packet, excluding pre-probe idle
+time. Piik's shared forwarding package uses this copy for both individual
+transports and publications. Its regression checks use real TWCC feedback and
+retain the no-feedback and idle-before-probe controls. The
+[media research owner](../../docs/research/node-local-media-design.md#send-queue-recovery)
+records the runtime evidence and remaining limits.
+
+The package notice collector still includes LiveKit's upstream license and
+notice through the other compiled LiveKit packages. When a maintained upstream
+version covers both corrections and the same regressions pass, remove this
+directory and restore the upstream imports. Review dependency bumps against the
+named baseline; do not evolve a separate congestion-control algorithm here.
+
+The retained detector also includes [upstream #4935](https://github.com/livekit/livekit/pull/4935)
+and its regression from v1.13.9, adapted to standard Go assertions. Reserved
+TWCC symbols carry no receive delta: skip their measurement rather than consuming
+the next packet's delta or indexing past the feedback array.
+
+## Pion Repaired-Packet Identity
+
+The shared [RTP receive adapter](../media/forwarding/codecs.go) uses the negotiated
+primary codec for packets Pion identifies as repaired. In Pion v4.2.23,
+[`receiveForRid`](https://github.com/pion/webrtc/blob/v4.2.23/rtpreceiver.go)
+initializes the codec before the first primary read initializes `PayloadType`;
+an earlier RTX packet can therefore be queued with payload type zero.
+Ordinary packet validation remains unchanged. A real simulcast/NACK regression
+fences that ordering without sleeps and fails when the correction is removed.
+Remove the adapter when a maintained upstream version passes that same control
+without it; no Pion source fork or second retransmission handler is retained.
diff --git a/internal/thirdparty/livekit/LICENSE b/internal/thirdparty/livekit/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/internal/thirdparty/livekit/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/internal/thirdparty/livekit/NOTICE b/internal/thirdparty/livekit/NOTICE
new file mode 100644
index 000000000..692adc992
--- /dev/null
+++ b/internal/thirdparty/livekit/NOTICE
@@ -0,0 +1,13 @@
+Copyright 2023 LiveKit, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/internal/thirdparty/livekit/sendsidebwe/congestion_detector.go b/internal/thirdparty/livekit/sendsidebwe/congestion_detector.go
new file mode 100644
index 000000000..c6c63a1fc
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/congestion_detector.go
@@ -0,0 +1,1169 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Modified by Piik: finalize probes from valid acknowledged measurements,
+// not the detector's retained initial capacity.
+// Includes upstream #4935: consume deltas only for TWCC symbols carrying one.
+package sendsidebwe
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/livekit/livekit-server/pkg/sfu/bwe"
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+ "github.com/livekit/protocol/logger"
+ "github.com/livekit/protocol/utils/mono"
+ "github.com/pion/rtcp"
+ "go.uber.org/zap/zapcore"
+)
+
+// -------------------------------------------------------------------------------
+
+type CongestionSignalConfig struct {
+ MinNumberOfGroups int `yaml:"min_number_of_groups,omitempty"`
+ MinDuration time.Duration `yaml:"min_duration,omitempty"`
+}
+
+func (c CongestionSignalConfig) IsTriggered(numGroups int, duration int64) bool {
+ return numGroups >= c.MinNumberOfGroups && duration >= c.MinDuration.Microseconds()
+}
+
+var (
+ defaultQueuingDelayEarlyWarningJQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 2,
+ MinDuration: 200 * time.Millisecond,
+ }
+
+ defaultQueuingDelayEarlyWarningDQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 3,
+ MinDuration: 300 * time.Millisecond,
+ }
+
+ defaultLossEarlyWarningJQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 3,
+ MinDuration: 300 * time.Millisecond,
+ }
+
+ defaultLossEarlyWarningDQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 4,
+ MinDuration: 400 * time.Millisecond,
+ }
+
+ defaultQueuingDelayCongestedJQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 4,
+ MinDuration: 400 * time.Millisecond,
+ }
+
+ defaultQueuingDelayCongestedDQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 5,
+ MinDuration: 500 * time.Millisecond,
+ }
+
+ defaultLossCongestedJQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 6,
+ MinDuration: 600 * time.Millisecond,
+ }
+
+ defaultLossCongestedDQRConfig = CongestionSignalConfig{
+ MinNumberOfGroups: 6,
+ MinDuration: 600 * time.Millisecond,
+ }
+)
+
+// -------------------------------------------------------------------------------
+
+type ProbeSignalConfig struct {
+ MinBytesRatio float64 `yaml:"min_bytes_ratio,omitempty"`
+ MinDurationRatio float64 `yaml:"min_duration_ratio,omitempty"`
+
+ JQRMinDelay time.Duration `yaml:"jqr_min_delay,omitempty"`
+ DQRMaxDelay time.Duration `yaml:"dqr_max_delay,omitempty"`
+
+ WeightedLoss WeightedLossConfig `yaml:"weighted_loss,omitempty"`
+ JQRMinWeightedLoss float64 `yaml:"jqr_min_weighted_loss,omitempty"`
+ DQRMaxWeightedLoss float64 `yaml:"dqr_max_weighted_loss,omitempty"`
+}
+
+func (p ProbeSignalConfig) IsValid(pci ccutils.ProbeClusterInfo) bool {
+ return pci.Result.Bytes() > int(p.MinBytesRatio*float64(pci.Goal.DesiredBytes)) && pci.Result.Duration() > time.Duration(p.MinDurationRatio*float64(pci.Goal.Duration))
+}
+
+func (p ProbeSignalConfig) ProbeSignal(ppg *probePacketGroup) (ccutils.ProbeSignal, int64) {
+ ts := newTrafficStats(trafficStatsParams{
+ Config: p.WeightedLoss,
+ })
+ ts.Merge(ppg.Traffic())
+
+ pqd := ppg.PropagatedQueuingDelay()
+ if pqd > p.JQRMinDelay.Microseconds() || ts.WeightedLoss() > p.JQRMinWeightedLoss {
+ return ccutils.ProbeSignalCongesting, ts.AcknowledgedBitrate()
+ }
+
+ if pqd < p.DQRMaxDelay.Microseconds() && ts.WeightedLoss() < p.DQRMaxWeightedLoss {
+ return ccutils.ProbeSignalNotCongesting, ts.AcknowledgedBitrate()
+ }
+
+ return ccutils.ProbeSignalInconclusive, ts.AcknowledgedBitrate()
+}
+
+var (
+ defaultProbeSignalConfig = ProbeSignalConfig{
+ MinBytesRatio: 0.5,
+ MinDurationRatio: 0.5,
+
+ JQRMinDelay: 50 * time.Millisecond,
+ DQRMaxDelay: 20 * time.Millisecond,
+
+ WeightedLoss: defaultWeightedLossConfig,
+ JQRMinWeightedLoss: 0.25,
+ DQRMaxWeightedLoss: 0.1,
+ }
+)
+
+// -------------------------------------------------------------------------------
+
+type queuingRegion int
+
+const (
+ queuingRegionDQR queuingRegion = iota
+ queuingRegionIndeterminate
+ queuingRegionJQR
+)
+
+func (q queuingRegion) String() string {
+ switch q {
+ case queuingRegionDQR:
+ return "DQR"
+ case queuingRegionIndeterminate:
+ return "INDETERMINATE"
+ case queuingRegionJQR:
+ return "JQR"
+ default:
+ return fmt.Sprintf("%d", int(q))
+ }
+}
+
+// -------------------------------------------------------------------------------
+
+type congestionReason int
+
+const (
+ congestionReasonNone congestionReason = iota
+ congestionReasonQueuingDelay
+ congestionReasonLoss
+)
+
+func (c congestionReason) String() string {
+ switch c {
+ case congestionReasonNone:
+ return "NONE"
+ case congestionReasonQueuingDelay:
+ return "QUEUING_DELAY"
+ case congestionReasonLoss:
+ return "LOSS"
+ default:
+ return fmt.Sprintf("%d", int(c))
+ }
+}
+
+// -------------------------------------------------------------------------------
+
+type qdMeasurement struct {
+ jqrConfig CongestionSignalConfig
+ dqrConfig CongestionSignalConfig
+ jqrMinDelay int64
+ jqrMinTrendCoefficient float64
+ dqrMaxDelay int64
+
+ numGroups int
+ numJQRGroups int
+ numDQRGroups int
+ minSendTime int64
+ maxSendTime int64
+ propagatedQueuingDelays []int64
+
+ isSealed bool
+ minGroupIdx int
+ maxGroupIdx int
+
+ queuingRegion queuingRegion
+}
+
+func newQDMeasurement(jqrConfig CongestionSignalConfig, dqrConfig CongestionSignalConfig, jqrMinDelay int64, jqrMinTrendCoefficient float64, dqrMaxDelay int64) *qdMeasurement {
+ return &qdMeasurement{
+ jqrConfig: jqrConfig,
+ dqrConfig: dqrConfig,
+ jqrMinDelay: jqrMinDelay,
+ jqrMinTrendCoefficient: jqrMinTrendCoefficient,
+ dqrMaxDelay: dqrMaxDelay,
+ queuingRegion: queuingRegionIndeterminate,
+ }
+}
+
+func (q *qdMeasurement) ProcessPacketGroup(pg *packetGroup, groupIdx int) {
+ if q.isSealed {
+ return
+ }
+
+ pqd, pqdOk := pg.FinalizedPropagatedQueuingDelay()
+ if !pqdOk {
+ return
+ }
+
+ q.numGroups++
+ if q.minGroupIdx == 0 || q.minGroupIdx > groupIdx {
+ q.minGroupIdx = groupIdx
+ }
+ q.maxGroupIdx = max(q.maxGroupIdx, groupIdx)
+
+ minSendTime, maxSendTime := pg.SendWindow()
+ if q.minSendTime == 0 || minSendTime < q.minSendTime {
+ q.minSendTime = minSendTime
+ }
+ q.maxSendTime = max(q.maxSendTime, maxSendTime)
+
+ q.propagatedQueuingDelays = append(q.propagatedQueuingDelays, pqd)
+
+ switch {
+ case pqd < q.dqrMaxDelay:
+ q.numDQRGroups++
+ if q.numJQRGroups > 0 {
+ // broken continuity, seal
+ q.isSealed = true
+ } else if q.dqrConfig.IsTriggered(q.numDQRGroups, q.maxSendTime-q.minSendTime) {
+ q.isSealed = true
+ q.queuingRegion = queuingRegionDQR
+ }
+
+ case pqd > q.jqrMinDelay:
+ q.numJQRGroups++
+ if q.numDQRGroups > 0 {
+ // broken continuity, seal
+ q.isSealed = true
+ } else if q.jqrConfig.IsTriggered(q.numJQRGroups, q.maxSendTime-q.minSendTime) && q.trendCoefficient() > q.jqrMinTrendCoefficient {
+ q.isSealed = true
+ q.queuingRegion = queuingRegionJQR
+ }
+
+ default:
+ if q.numDQRGroups > 0 || q.numJQRGroups > 0 {
+ // broken continuity, seal
+ q.isSealed = true
+ }
+ }
+}
+
+func (q *qdMeasurement) IsSealed() bool {
+ return q.isSealed
+}
+
+func (q *qdMeasurement) QueuingRegion() queuingRegion {
+ return q.queuingRegion
+}
+
+func (q *qdMeasurement) GroupRange() (int, int) {
+ return max(0, q.minGroupIdx), max(0, q.maxGroupIdx)
+}
+
+func (q *qdMeasurement) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if q == nil {
+ return nil
+ }
+
+ e.AddInt("numGroups", q.numGroups)
+ e.AddInt("numJQRGroups", q.numJQRGroups)
+ e.AddInt("numDQRGroups", q.numDQRGroups)
+ e.AddInt64("minSendTime", q.minSendTime)
+ e.AddInt64("maxSendTime", q.maxSendTime)
+ e.AddArray("propagatedQueuingDelays", logger.Int64Slice(q.propagatedQueuingDelays))
+ e.AddFloat64("trendCoefficient", q.trendCoefficient())
+ e.AddDuration("duration", time.Duration((q.maxSendTime-q.minSendTime)*1000))
+ e.AddBool("isSealed", q.isSealed)
+ e.AddInt("minGroupIdx", q.minGroupIdx)
+ e.AddInt("maxGroupIdx", q.maxGroupIdx)
+ e.AddString("queuingRegion", q.queuingRegion.String())
+ return nil
+}
+
+func (q *qdMeasurement) trendCoefficient() float64 {
+ concordantPairs := 0
+ discordantPairs := 0
+
+ // the packet groups are processed from newest to oldest,
+ // so a concordant pair is when the value drops,
+ // i. e. the propagated queuing delay is increasing if newer (earlier entity in slice) is higher than older (later entity in slice)
+ for i := 0; i < len(q.propagatedQueuingDelays)-1; i++ {
+ for j := i + 1; j < len(q.propagatedQueuingDelays); j++ {
+ if q.propagatedQueuingDelays[i] > q.propagatedQueuingDelays[j] {
+ concordantPairs++
+ } else if q.propagatedQueuingDelays[i] < q.propagatedQueuingDelays[j] {
+ discordantPairs++
+ }
+ }
+ }
+
+ if (concordantPairs + discordantPairs) == 0 {
+ // if the min requirements is only one sample, trend calculation is not possible, declare highest trend value
+ if len(q.propagatedQueuingDelays) == 1 {
+ return 1.0
+ }
+
+ return 0.0
+ }
+
+ return (float64(concordantPairs) - float64(discordantPairs)) / (float64(concordantPairs) + float64(discordantPairs))
+}
+
+// -------------------------------------------------------------------------------
+
+type lossMeasurement struct {
+ jqrConfig CongestionSignalConfig
+ dqrConfig CongestionSignalConfig
+ jqrMinLoss float64
+ dqrMaxLoss float64
+
+ numGroups int
+ ts *trafficStats
+
+ isJQRSealed bool
+ isDQRSealed bool
+ minGroupIdx int
+ maxGroupIdx int
+
+ weightedLoss float64
+
+ queuingRegion queuingRegion
+}
+
+func newLossMeasurement(
+ jqrConfig CongestionSignalConfig,
+ dqrConfig CongestionSignalConfig,
+ weightedLossConfig WeightedLossConfig,
+ jqrMinLoss float64,
+ dqrMaxLoss float64,
+ logger logger.Logger,
+) *lossMeasurement {
+ return &lossMeasurement{
+ jqrConfig: jqrConfig,
+ dqrConfig: dqrConfig,
+ jqrMinLoss: jqrMinLoss,
+ dqrMaxLoss: dqrMaxLoss,
+ ts: newTrafficStats(trafficStatsParams{
+ Config: weightedLossConfig,
+ Logger: logger,
+ }),
+ queuingRegion: queuingRegionIndeterminate,
+ }
+}
+
+func (l *lossMeasurement) ProcessPacketGroup(pg *packetGroup, groupIdx int) {
+ if (l.isJQRSealed && l.isDQRSealed) || !pg.IsFinalized() {
+ return
+ }
+
+ l.numGroups++
+ if l.minGroupIdx == 0 || l.minGroupIdx > groupIdx {
+ l.minGroupIdx = groupIdx
+ }
+ l.maxGroupIdx = max(l.maxGroupIdx, groupIdx)
+
+ l.ts.Merge(pg.Traffic())
+
+ if !l.isJQRSealed && l.jqrConfig.IsTriggered(l.numGroups, l.ts.Duration()) {
+ l.isJQRSealed = true
+
+ weightedLoss := l.ts.WeightedLoss()
+ if weightedLoss > l.jqrMinLoss {
+ l.weightedLoss = weightedLoss
+ l.queuingRegion = queuingRegionJQR
+ l.isDQRSealed = true // seal DQR also as queuing region has been determined
+ return
+ }
+ }
+
+ if !l.isDQRSealed && l.dqrConfig.IsTriggered(l.numGroups, l.ts.Duration()) {
+ l.isDQRSealed = true
+
+ weightedLoss := l.ts.WeightedLoss()
+ if weightedLoss < l.dqrMaxLoss {
+ l.weightedLoss = weightedLoss
+ l.queuingRegion = queuingRegionDQR
+ l.isJQRSealed = true // seal JQR also as queuing region has been determined
+ return
+ }
+ }
+}
+
+func (l *lossMeasurement) IsSealed() bool {
+ return l.isJQRSealed && l.isDQRSealed
+}
+
+func (l *lossMeasurement) QueuingRegion() queuingRegion {
+ return l.queuingRegion
+}
+
+func (l *lossMeasurement) GroupRange() (int, int) {
+ return max(0, l.minGroupIdx), max(0, l.maxGroupIdx)
+}
+
+func (l *lossMeasurement) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if l == nil {
+ return nil
+ }
+
+ e.AddInt("numGroups", l.numGroups)
+ e.AddObject("ts", l.ts)
+ e.AddBool("isJQRSealed", l.isJQRSealed)
+ e.AddBool("isDQRSealed", l.isDQRSealed)
+ e.AddInt("minGroupIdx", l.minGroupIdx)
+ e.AddInt("maxGroupIdx", l.maxGroupIdx)
+ e.AddFloat64("weightedLoss", l.weightedLoss)
+ e.AddString("queuingRegion", l.queuingRegion.String())
+ return nil
+}
+
+// -------------------------------------------------------------------------------
+
+type CongestionDetectorConfig struct {
+ PacketGroup PacketGroupConfig `yaml:"packet_group,omitempty"`
+ PacketGroupMaxAge time.Duration `yaml:"packet_group_max_age,omitempty"`
+
+ ProbePacketGroup ProbePacketGroupConfig `yaml:"probe_packet_group,omitempty"`
+ ProbeRegulator ccutils.ProbeRegulatorConfig `yaml:"probe_regulator,omitempty"`
+ ProbeSignal ProbeSignalConfig `yaml:"probe_signal,omitempty"`
+
+ JQRMinDelay time.Duration `yaml:"jqr_min_delay,omitempty"`
+ JQRMinTrendCoefficient float64 `yaml:"jqr_min_trend_coefficient,omitempty"`
+ DQRMaxDelay time.Duration `yaml:"dqr_max_delay,omitempty"`
+
+ WeightedLoss WeightedLossConfig `yaml:"weighted_loss,omitempty"`
+ JQRMinWeightedLoss float64 `yaml:"jqr_min_weighted_loss,omitempty"`
+ DQRMaxWeightedLoss float64 `yaml:"dqr_max_weighted_loss,omitempty"`
+
+ QueuingDelayEarlyWarningJQR CongestionSignalConfig `yaml:"queuing_delay_early_warning_jqr,omitempty"`
+ QueuingDelayEarlyWarningDQR CongestionSignalConfig `yaml:"queuing_delay_early_warning_dqr,omitempty"`
+ LossEarlyWarningJQR CongestionSignalConfig `yaml:"loss_early_warning_jqr,omitempty"`
+ LossEarlyWarningDQR CongestionSignalConfig `yaml:"loss_early_warning_dqr,omitempty"`
+
+ QueuingDelayCongestedJQR CongestionSignalConfig `yaml:"queuing_delay_congested_jqr,omitempty"`
+ QueuingDelayCongestedDQR CongestionSignalConfig `yaml:"queuing_delay_congested_dqr,omitempty"`
+ LossCongestedJQR CongestionSignalConfig `yaml:"loss_congested_jqr,omitempty"`
+ LossCongestedDQR CongestionSignalConfig `yaml:"loss_congested_dqr,omitempty"`
+
+ CongestedCTRTrend ccutils.TrendDetectorConfig `yaml:"congested_ctr_trend,omitempty"`
+ CongestedCTREpsilon float64 `yaml:"congested_ctr_epsilon,omitempty"`
+ CongestedPacketGroup PacketGroupConfig `yaml:"congested_packet_group,omitempty"`
+
+ EstimationWindowDuration time.Duration `yaml:"estimaton_window_duration,omitempty"`
+}
+
+var (
+ defaultTrendDetectorConfigCongestedCTR = ccutils.TrendDetectorConfig{
+ RequiredSamples: 4,
+ RequiredSamplesMin: 2,
+ DownwardTrendThreshold: -0.5,
+ DownwardTrendMaxWait: 2 * time.Second,
+ CollapseThreshold: 500 * time.Millisecond,
+ ValidityWindow: 10 * time.Second,
+ }
+
+ defaultCongestedPacketGroupConfig = PacketGroupConfig{
+ MinPackets: 20,
+ MaxWindowDuration: 150 * time.Millisecond,
+ }
+
+ defaultCongestionDetectorConfig = CongestionDetectorConfig{
+ PacketGroup: defaultPacketGroupConfig,
+ PacketGroupMaxAge: 10 * time.Second,
+
+ ProbePacketGroup: defaultProbePacketGroupConfig,
+ ProbeRegulator: ccutils.DefaultProbeRegulatorConfig,
+ ProbeSignal: defaultProbeSignalConfig,
+
+ JQRMinDelay: 50 * time.Millisecond,
+ JQRMinTrendCoefficient: 0.8,
+ DQRMaxDelay: 20 * time.Millisecond,
+
+ WeightedLoss: defaultWeightedLossConfig,
+ JQRMinWeightedLoss: 0.25,
+ DQRMaxWeightedLoss: 0.1,
+
+ QueuingDelayEarlyWarningJQR: defaultQueuingDelayEarlyWarningJQRConfig,
+ QueuingDelayEarlyWarningDQR: defaultQueuingDelayEarlyWarningDQRConfig,
+ LossEarlyWarningJQR: defaultLossEarlyWarningJQRConfig,
+ LossEarlyWarningDQR: defaultLossEarlyWarningDQRConfig,
+
+ QueuingDelayCongestedJQR: defaultQueuingDelayCongestedJQRConfig,
+ QueuingDelayCongestedDQR: defaultQueuingDelayCongestedDQRConfig,
+ LossCongestedJQR: defaultLossCongestedJQRConfig,
+ LossCongestedDQR: defaultLossCongestedDQRConfig,
+
+ CongestedCTRTrend: defaultTrendDetectorConfigCongestedCTR,
+ CongestedCTREpsilon: 0.05,
+ CongestedPacketGroup: defaultCongestedPacketGroupConfig,
+
+ EstimationWindowDuration: time.Second,
+ }
+)
+
+// -------------------------------------------------------------------------------
+
+type congestionDetectorParams struct {
+ Config CongestionDetectorConfig
+ Logger logger.Logger
+}
+
+type congestionDetector struct {
+ params congestionDetectorParams
+
+ lock sync.Mutex
+
+ rtt float64
+
+ *packetTracker
+ twccFeedback *twccFeedback
+
+ packetGroups []*packetGroup
+
+ probePacketGroup *probePacketGroup
+ probeRegulator *ccutils.ProbeRegulator
+
+ estimatedAvailableChannelCapacity int64
+ estimateTrafficStats *trafficStats
+
+ congestionState bwe.CongestionState
+ congestionStateSwitchedAt time.Time
+
+ congestedCTRTrend *ccutils.TrendDetector[float64]
+ congestedTrafficStats *trafficStats
+ congestedPacketGroup *packetGroup
+
+ congestionReason congestionReason
+ qdMeasurement *qdMeasurement
+ lossMeasurement *lossMeasurement
+
+ bweListener bwe.BWEListener
+}
+
+func newCongestionDetector(params congestionDetectorParams) *congestionDetector {
+ c := &congestionDetector{
+ params: params,
+ packetTracker: newPacketTracker(packetTrackerParams{Logger: params.Logger}),
+ twccFeedback: newTWCCFeedback(twccFeedbackParams{Logger: params.Logger}),
+ }
+ c.Reset()
+
+ return c
+}
+
+func (c *congestionDetector) Reset() {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ c.rtt = bwe.DefaultRTT
+
+ c.packetGroups = nil
+
+ c.probePacketGroup = nil
+ c.probeRegulator = ccutils.NewProbeRegulator(ccutils.ProbeRegulatorParams{
+ Config: c.params.Config.ProbeRegulator,
+ Logger: c.params.Logger,
+ })
+
+ c.estimatedAvailableChannelCapacity = 100_000_000
+ c.estimateTrafficStats = nil
+
+ c.congestionState = bwe.CongestionStateNone
+ c.congestionStateSwitchedAt = mono.Now()
+
+ c.clearCTRTrend()
+
+ c.congestionReason = congestionReasonNone
+ c.qdMeasurement = nil
+ c.lossMeasurement = nil
+}
+
+func (c *congestionDetector) SetBWEListener(bweListener bwe.BWEListener) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ c.bweListener = bweListener
+}
+
+func (c *congestionDetector) getBWEListener() bwe.BWEListener {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ return c.bweListener
+}
+
+func (c *congestionDetector) HandleTWCCFeedback(report *rtcp.TransportLayerCC) {
+ c.lock.Lock()
+ recvRefTime, _ := c.twccFeedback.ProcessReport(report, mono.Now())
+
+ if len(c.packetGroups) == 0 {
+ c.packetGroups = append(
+ c.packetGroups,
+ newPacketGroup(
+ packetGroupParams{
+ Config: c.params.Config.PacketGroup,
+ WeightedLoss: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ },
+ 0,
+ ),
+ )
+ }
+
+ pg := c.packetGroups[len(c.packetGroups)-1]
+ trackPacketGroup := func(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) {
+ if pi == nil {
+ return
+ }
+
+ c.updateCTRTrend(pi, sendDelta, recvDelta, isLost)
+
+ if c.probePacketGroup != nil {
+ c.probePacketGroup.Add(pi, sendDelta, recvDelta, isLost)
+ }
+
+ err := pg.Add(pi, sendDelta, recvDelta, isLost)
+ if err == nil {
+ return
+ }
+
+ if err == errGroupFinalized {
+ // previous group ended, start a new group
+ pg = newPacketGroup(
+ packetGroupParams{
+ Config: c.params.Config.PacketGroup,
+ WeightedLoss: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ },
+ pg.PropagatedQueuingDelay(),
+ )
+ c.packetGroups = append(c.packetGroups, pg)
+
+ if err = pg.Add(pi, sendDelta, recvDelta, isLost); err != nil {
+ c.params.Logger.Warnw("send side bwe: could not add packet to new packet group", err, "packetInfo", pi, "packetGroup", pg)
+ }
+ return
+ }
+
+ // try an older group
+ for idx := len(c.packetGroups) - 2; idx >= 0; idx-- {
+ opg := c.packetGroups[idx]
+ if err := opg.Add(pi, sendDelta, recvDelta, isLost); err == nil {
+ return
+ } else if err == errGroupFinalized {
+ c.params.Logger.Infow("send side bwe: unexpected finalized group", "packetInfo", pi, "packetGroup", opg)
+ }
+ }
+ }
+
+ // 1. go through the TWCC feedback report and record receive time as reported by remote
+ // 2. process acknowledged packet and group them
+ //
+ // losses are not recorded if a feedback report is completely lost.
+ // RFC recommends treating lost reports by ignoring packets that would have been in it.
+ // -----------------------------------------------------------------------------------
+ // | From a congestion control perspective, lost feedback messages are |
+ // | handled by ignoring packets which would have been reported as lost or |
+ // | received in the lost feedback messages. This behavior is similar to |
+ // | how a lost RTCP receiver report is handled. |
+ // -----------------------------------------------------------------------------------
+ // Reference: https://datatracker.ietf.org/doc/html/draft-holmer-rmcat-transport-wide-cc-extensions-01#page-4
+ sequenceNumber := report.BaseSequenceNumber
+ endSequenceNumberExclusive := sequenceNumber + report.PacketStatusCount
+ deltaIdx := 0
+ processSymbol := func(symbol uint16) {
+ recvTime := int64(0)
+ isLost := false
+ if symbol == rtcp.TypeTCCPacketNotReceived {
+ isLost = true
+ } else if (symbol == rtcp.TypeTCCPacketReceivedSmallDelta || symbol == rtcp.TypeTCCPacketReceivedLargeDelta) && deltaIdx < len(report.RecvDeltas) {
+ recvRefTime += report.RecvDeltas[deltaIdx].Delta
+ deltaIdx++
+
+ recvTime = recvRefTime
+ } else {
+ // The reserved symbol carries no receive delta, nothing to record.
+ sequenceNumber++
+ return
+ }
+ pi, sendDelta, recvDelta := c.packetTracker.RecordPacketIndicationFromRemote(sequenceNumber, recvTime)
+ if pi.sendTime != 0 {
+ trackPacketGroup(&pi, sendDelta, recvDelta, isLost)
+ }
+ sequenceNumber++
+ }
+ for _, chunk := range report.PacketChunks {
+ if sequenceNumber == endSequenceNumberExclusive {
+ break
+ }
+
+ switch chunk := chunk.(type) {
+ case *rtcp.RunLengthChunk:
+ for i := uint16(0); i < chunk.RunLength; i++ {
+ if sequenceNumber == endSequenceNumberExclusive {
+ break
+ }
+
+ processSymbol(chunk.PacketStatusSymbol)
+ }
+
+ case *rtcp.StatusVectorChunk:
+ for _, symbol := range chunk.SymbolList {
+ if sequenceNumber == endSequenceNumberExclusive {
+ break
+ }
+
+ processSymbol(symbol)
+ }
+ }
+ }
+
+ c.prunePacketGroups()
+ shouldNotify, fromState, toState, committedChannelCapacity := c.congestionDetectionStateMachine()
+ c.lock.Unlock()
+
+ if shouldNotify {
+ if bweListener := c.getBWEListener(); bweListener != nil {
+ bweListener.OnCongestionStateChange(fromState, toState, committedChannelCapacity)
+ }
+ }
+}
+
+func (c *congestionDetector) UpdateRTT(rtt float64) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if rtt == 0 {
+ c.rtt = bwe.DefaultRTT
+ } else {
+ if c.rtt == 0 {
+ c.rtt = rtt
+ } else {
+ c.rtt = bwe.RTTSmoothingFactor*rtt + (1.0-bwe.RTTSmoothingFactor)*c.rtt
+ }
+ }
+}
+
+func (c *congestionDetector) CongestionState() bwe.CongestionState {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ return c.congestionState
+}
+
+func (c *congestionDetector) CanProbe() bool {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ return c.congestionState == bwe.CongestionStateNone && c.probePacketGroup == nil && c.probeRegulator.CanProbe()
+}
+
+func (c *congestionDetector) ProbeDuration() time.Duration {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ return c.probeRegulator.ProbeDuration()
+}
+
+func (c *congestionDetector) ProbeClusterStarting(pci ccutils.ProbeClusterInfo) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ c.probePacketGroup = newProbePacketGroup(
+ probePacketGroupParams{
+ Config: c.params.Config.ProbePacketGroup,
+ WeightedLoss: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ },
+ pci,
+ )
+
+ c.packetTracker.ProbeClusterStarting(pci.Id)
+}
+
+func (c *congestionDetector) ProbeClusterDone(pci ccutils.ProbeClusterInfo) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ c.packetTracker.ProbeClusterDone(pci.Id)
+ if c.probePacketGroup != nil {
+ c.probePacketGroup.ProbeClusterDone(pci)
+ }
+}
+
+func (c *congestionDetector) ProbeClusterIsGoalReached() bool {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if c.probePacketGroup == nil || c.congestionState != bwe.CongestionStateNone {
+ return false
+ }
+
+ pci := c.probePacketGroup.ProbeClusterInfo()
+ if !c.params.Config.ProbeSignal.IsValid(pci) {
+ return false
+ }
+
+ probeSignal, estimatedAvailableChannelCapacity := c.params.Config.ProbeSignal.ProbeSignal(c.probePacketGroup)
+ return probeSignal != ccutils.ProbeSignalNotCongesting && estimatedAvailableChannelCapacity > int64(pci.Goal.DesiredBps)
+}
+
+func (c *congestionDetector) ProbeClusterFinalize() (ccutils.ProbeSignal, int64, bool) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if c.probePacketGroup == nil {
+ return ccutils.ProbeSignalInconclusive, 0, false
+ }
+
+ pci, isFinalized := c.probePacketGroup.MaybeFinalizeProbe(c.packetTracker.ProbeMaxSequenceNumber(), c.rtt)
+ if !isFinalized {
+ return ccutils.ProbeSignalInconclusive, 0, isFinalized
+ }
+
+ isSignalValid := c.params.Config.ProbeSignal.IsValid(pci)
+ c.params.Logger.Infow(
+ "send side bwe: probe finalized",
+ "isSignalValid", isSignalValid,
+ "probeClusterInfo", pci,
+ "probePacketGroup", c.probePacketGroup,
+ "congestionState", c.congestionState,
+ "rtt", c.rtt,
+ )
+
+ // if congestion signal changed during probe, defer to that signal
+ if c.congestionState != bwe.CongestionStateNone {
+ probeSignal := ccutils.ProbeSignalCongesting
+ c.probeRegulator.ProbeSignal(probeSignal, pci.CreatedAt)
+ c.probePacketGroup = nil
+ return probeSignal, c.estimatedAvailableChannelCapacity, true
+ }
+
+ probeSignal, estimatedAvailableChannelCapacity := c.params.Config.ProbeSignal.ProbeSignal(c.probePacketGroup)
+ if !isSignalValid || estimatedAvailableChannelCapacity <= 0 {
+ probeSignal = ccutils.ProbeSignalInconclusive
+ }
+ if probeSignal == ccutils.ProbeSignalNotCongesting && estimatedAvailableChannelCapacity > c.estimatedAvailableChannelCapacity {
+ c.estimatedAvailableChannelCapacity = estimatedAvailableChannelCapacity
+ }
+
+ c.probeRegulator.ProbeSignal(probeSignal, pci.CreatedAt)
+ c.probePacketGroup = nil
+ return probeSignal, estimatedAvailableChannelCapacity, true
+}
+
+func (c *congestionDetector) prunePacketGroups() {
+ if len(c.packetGroups) == 0 {
+ return
+ }
+
+ threshold, ok := c.packetTracker.BaseSendTimeThreshold(c.params.Config.PacketGroupMaxAge.Microseconds())
+ if !ok {
+ return
+ }
+
+ for idx, pg := range c.packetGroups {
+ if mst := pg.MinSendTime(); mst > threshold {
+ c.packetGroups = c.packetGroups[idx:]
+ return
+ }
+ }
+}
+
+func (c *congestionDetector) updateCongestionSignal(
+ qdJQRConfig CongestionSignalConfig,
+ qdDQRConfig CongestionSignalConfig,
+ lossJQRConfig CongestionSignalConfig,
+ lossDQRConfig CongestionSignalConfig,
+) queuingRegion {
+ c.qdMeasurement = newQDMeasurement(
+ qdJQRConfig,
+ qdDQRConfig,
+ c.params.Config.JQRMinDelay.Microseconds(),
+ c.params.Config.JQRMinTrendCoefficient,
+ c.params.Config.DQRMaxDelay.Microseconds(),
+ )
+ c.lossMeasurement = newLossMeasurement(
+ lossJQRConfig,
+ lossDQRConfig,
+ c.params.Config.WeightedLoss,
+ c.params.Config.JQRMinWeightedLoss,
+ c.params.Config.DQRMaxWeightedLoss,
+ c.params.Logger,
+ )
+
+ var idx int
+ for idx = len(c.packetGroups) - 1; idx >= 0; idx-- {
+ pg := c.packetGroups[idx]
+ c.qdMeasurement.ProcessPacketGroup(pg, idx)
+ c.lossMeasurement.ProcessPacketGroup(pg, idx)
+
+ // if both measurements have enough data to make a decision, stop processing groups
+ if c.qdMeasurement.IsSealed() && c.lossMeasurement.IsSealed() {
+ break
+ }
+ }
+
+ qr := queuingRegionIndeterminate
+ qdQueuingRegion := c.qdMeasurement.QueuingRegion()
+ lossQueuingRegion := c.lossMeasurement.QueuingRegion()
+ switch {
+ case qdQueuingRegion == queuingRegionJQR:
+ qr = queuingRegionJQR
+ c.congestionReason = congestionReasonQueuingDelay
+ case lossQueuingRegion == queuingRegionJQR:
+ qr = queuingRegionJQR
+ c.congestionReason = congestionReasonLoss
+ case qdQueuingRegion == queuingRegionDQR && lossQueuingRegion == queuingRegionDQR:
+ qr = queuingRegionDQR
+ c.congestionReason = congestionReasonNone
+ }
+
+ return qr
+}
+
+func (c *congestionDetector) updateEarlyWarningSignal() queuingRegion {
+ return c.updateCongestionSignal(
+ c.params.Config.QueuingDelayEarlyWarningJQR,
+ c.params.Config.QueuingDelayEarlyWarningDQR,
+ c.params.Config.LossEarlyWarningJQR,
+ c.params.Config.LossEarlyWarningDQR,
+ )
+}
+
+func (c *congestionDetector) updateCongestedSignal() queuingRegion {
+ return c.updateCongestionSignal(
+ c.params.Config.QueuingDelayCongestedJQR,
+ c.params.Config.QueuingDelayCongestedDQR,
+ c.params.Config.LossCongestedJQR,
+ c.params.Config.LossCongestedDQR,
+ )
+}
+
+func (c *congestionDetector) congestionDetectionStateMachine() (bool, bwe.CongestionState, bwe.CongestionState, int64) {
+ fromState := c.congestionState
+ toState := c.congestionState
+
+ switch fromState {
+ case bwe.CongestionStateNone:
+ if c.updateEarlyWarningSignal() == queuingRegionJQR {
+ toState = bwe.CongestionStateEarlyWarning
+ }
+
+ case bwe.CongestionStateEarlyWarning:
+ if c.updateCongestedSignal() == queuingRegionJQR {
+ toState = bwe.CongestionStateCongested
+ } else if c.updateEarlyWarningSignal() == queuingRegionDQR {
+ toState = bwe.CongestionStateNone
+ }
+
+ case bwe.CongestionStateCongested:
+ if c.updateCongestedSignal() == queuingRegionDQR {
+ toState = bwe.CongestionStateNone
+ }
+ }
+
+ shouldNotify := false
+ if toState != fromState {
+ c.estimateAvailableChannelCapacity()
+ fromState, toState = c.updateCongestionState(toState)
+ shouldNotify = true
+ }
+
+ if c.congestedCTRTrend != nil && c.congestedCTRTrend.GetDirection() == ccutils.TrendDirectionDownward {
+ congestedAckedBitrate := c.congestedTrafficStats.AcknowledgedBitrate()
+ if congestedAckedBitrate < c.estimatedAvailableChannelCapacity {
+ c.estimatedAvailableChannelCapacity = congestedAckedBitrate
+
+ c.params.Logger.Infow(
+ "send side bwe: captured traffic ratio is trending downward",
+ "channel", c.congestedCTRTrend,
+ "trafficStats", c.congestedTrafficStats,
+ "estimatedAvailableChannelCapacity", c.estimatedAvailableChannelCapacity,
+ )
+
+ shouldNotify = true
+ }
+
+ // reset to get new set of samples for next trend
+ c.resetCTRTrend()
+ }
+
+ return shouldNotify, fromState, toState, c.estimatedAvailableChannelCapacity
+}
+
+func (c *congestionDetector) createCTRTrend() {
+ c.resetCTRTrend()
+ c.congestedPacketGroup = nil
+}
+
+func (c *congestionDetector) resetCTRTrend() {
+ c.congestedCTRTrend = ccutils.NewTrendDetector[float64](ccutils.TrendDetectorParams{
+ Name: "ssbwe-ctr",
+ Logger: c.params.Logger,
+ Config: c.params.Config.CongestedCTRTrend,
+ })
+ c.congestedTrafficStats = newTrafficStats(trafficStatsParams{
+ Config: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ })
+}
+
+func (c *congestionDetector) clearCTRTrend() {
+ c.congestedCTRTrend = nil
+ c.congestedTrafficStats = nil
+ c.congestedPacketGroup = nil
+}
+
+func (c *congestionDetector) updateCTRTrend(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) {
+ if c.congestedCTRTrend == nil {
+ return
+ }
+
+ if c.congestedPacketGroup == nil {
+ c.congestedPacketGroup = newPacketGroup(
+ packetGroupParams{
+ Config: c.params.Config.CongestedPacketGroup,
+ WeightedLoss: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ },
+ 0,
+ )
+ }
+
+ if err := c.congestedPacketGroup.Add(pi, sendDelta, recvDelta, isLost); err == errGroupFinalized {
+ // progressively keep increasing the window and make measurements over longer windows,
+ // if congestion is not relieving, CTR will trend down
+ c.congestedTrafficStats.Merge(c.congestedPacketGroup.Traffic())
+
+ ts := newTrafficStats(trafficStatsParams{
+ Config: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ })
+ ts.Merge(c.congestedPacketGroup.Traffic())
+ ctr := ts.CapturedTrafficRatio()
+
+ // quantise CTR to filter out small changes
+ c.congestedCTRTrend.AddValue(float64(int((ctr+(c.params.Config.CongestedCTREpsilon/2))/c.params.Config.CongestedCTREpsilon)) * c.params.Config.CongestedCTREpsilon)
+
+ c.congestedPacketGroup = newPacketGroup(
+ packetGroupParams{
+ Config: c.params.Config.CongestedPacketGroup,
+ WeightedLoss: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ },
+ c.congestedPacketGroup.PropagatedQueuingDelay(),
+ )
+ }
+}
+
+func (c *congestionDetector) estimateAvailableChannelCapacity() {
+ c.estimateTrafficStats = nil
+ if len(c.packetGroups) == 0 || c.probePacketGroup != nil {
+ return
+ }
+
+ // when congested, use contributing groups,
+ // else use a time windowed measurement
+ useWindow := false
+ isAggValid := true
+ minGroupIdx := 0
+ maxGroupIdx := len(c.packetGroups) - 1
+ switch c.congestionReason {
+ case congestionReasonQueuingDelay:
+ minGroupIdx, maxGroupIdx = c.qdMeasurement.GroupRange()
+ case congestionReasonLoss:
+ minGroupIdx, maxGroupIdx = c.lossMeasurement.GroupRange()
+ default:
+ useWindow = true
+ isAggValid = false
+ }
+
+ agg := newTrafficStats(trafficStatsParams{
+ Config: c.params.Config.WeightedLoss,
+ Logger: c.params.Logger,
+ })
+ for idx := maxGroupIdx; idx >= minGroupIdx; idx-- {
+ pg := c.packetGroups[idx]
+ if !pg.IsFinalized() {
+ continue
+ }
+
+ agg.Merge(pg.Traffic())
+ if useWindow && agg.Duration() > c.params.Config.EstimationWindowDuration.Microseconds() {
+ isAggValid = true
+ break
+ }
+ }
+
+ if isAggValid {
+ c.estimatedAvailableChannelCapacity = agg.AcknowledgedBitrate()
+ c.estimateTrafficStats = agg
+ }
+}
+
+func (c *congestionDetector) updateCongestionState(state bwe.CongestionState) (bwe.CongestionState, bwe.CongestionState) {
+ loggingFields := []any{
+ "from", c.congestionState,
+ "to", state,
+ "congestionReason", c.congestionReason,
+ "qdMeasurement", c.qdMeasurement,
+ "lossMeasurement", c.lossMeasurement,
+ "numPacketGroups", len(c.packetGroups),
+ "estimatedAvailableChannelCapacity", c.estimatedAvailableChannelCapacity,
+ "estimateTrafficStats", c.estimateTrafficStats,
+ }
+ if c.congestionReason != congestionReasonNone {
+ var minGroupIdx, maxGroupIdx int
+ switch c.congestionReason {
+ case congestionReasonQueuingDelay:
+ minGroupIdx, maxGroupIdx = c.qdMeasurement.GroupRange()
+ case congestionReasonLoss:
+ minGroupIdx, maxGroupIdx = c.lossMeasurement.GroupRange()
+ }
+ loggingFields = append(
+ loggingFields,
+ "contributingGroups", logger.ObjectSlice(c.packetGroups[minGroupIdx:maxGroupIdx+1]),
+ )
+ }
+ c.params.Logger.Infow("send side bwe: congestion state change", loggingFields...)
+
+ if state != c.congestionState {
+ c.congestionStateSwitchedAt = mono.Now()
+ }
+
+ fromState := c.congestionState
+ c.congestionState = state
+
+ // when in congested state, monitor changes in captured traffic ratio (CTR)
+ // to ensure allocations are in line with latest estimates, it is possible that
+ // the estimate is incorrect when congestion starts and the allocation may be
+ // sub-optimal and not enough to reduce/relieve congestion, by monitoring CTR
+ // on a continuous basis allocations can be adjusted in the direction of
+ // reducing/relieving congestion
+ if state == bwe.CongestionStateCongested && fromState != bwe.CongestionStateCongested {
+ c.createCTRTrend()
+ } else if state != bwe.CongestionStateCongested {
+ c.clearCTRTrend()
+ }
+
+ return fromState, c.congestionState
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/congestion_detector_test.go b/internal/thirdparty/livekit/sendsidebwe/congestion_detector_test.go
new file mode 100644
index 000000000..c144bfcf8
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/congestion_detector_test.go
@@ -0,0 +1,114 @@
+// Copyright 2026 LiveKit, Inc.
+// Adapted from LiveKit v1.13.9 to use Go's standard testing package.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "testing"
+
+ "github.com/pion/rtcp"
+
+ "github.com/livekit/protocol/logger"
+ "github.com/livekit/protocol/utils/mono"
+
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+)
+
+// the reserved symbol has no receive delta, so its packet is neither acked nor lost,
+// and the next received packet uses the next delta
+func TestHandleTWCCFeedbackReservedSymbol(t *testing.T) {
+ // small delta, reserved, reserved, small delta, not received
+ for name, chunks := range map[string][]rtcp.PacketStatusChunk{
+ "status vector": {
+ &rtcp.StatusVectorChunk{
+ SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit,
+ SymbolList: []uint16{
+ rtcp.TypeTCCPacketReceivedSmallDelta,
+ rtcp.TypeTCCPacketReceivedWithoutDelta,
+ rtcp.TypeTCCPacketReceivedWithoutDelta,
+ rtcp.TypeTCCPacketReceivedSmallDelta,
+ rtcp.TypeTCCPacketNotReceived,
+ rtcp.TypeTCCPacketNotReceived,
+ rtcp.TypeTCCPacketNotReceived,
+ },
+ },
+ },
+ "run length": {
+ &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1},
+ &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedWithoutDelta, RunLength: 2},
+ &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1},
+ &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketNotReceived, RunLength: 1},
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ c := newCongestionDetector(congestionDetectorParams{
+ Config: defaultCongestionDetectorConfig,
+ Logger: logger.GetLogger(),
+ })
+ // the first packet sent is the send time base and never joins a group, so keep it out of the report
+ now := mono.UnixMicro()
+ c.RecordPacketSendAndGetSequenceNumber(now, 1200, false, ccutils.ProbeClusterIdInvalid, false)
+ sns := make([]uint16, 5)
+ for i := range sns {
+ sns[i] = c.RecordPacketSendAndGetSequenceNumber(now+int64(i+1)*1000, 1200, false, ccutils.ProbeClusterIdInvalid, false)
+ }
+
+ fb := &rtcp.TransportLayerCC{
+ Header: rtcp.Header{
+ Count: rtcp.FormatTCC,
+ Type: rtcp.TypeTransportSpecificFeedback,
+ },
+ MediaSSRC: 1,
+ BaseSequenceNumber: sns[0],
+ PacketStatusCount: uint16(len(sns)),
+ ReferenceTime: 100,
+ FbPktCount: 1,
+ PacketChunks: chunks,
+ RecvDeltas: []*rtcp.RecvDelta{
+ {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 1000},
+ {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 2000},
+ },
+ }
+ fb.Header.Padding = fb.MarshalSize()%4 != 0
+ fb.Header.Length = uint16(fb.MarshalSize()/4 - 1)
+
+ // round trip so the report is exactly what a remote peer can deliver
+ raw, err := fb.Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pkts, err := rtcp.Unmarshal(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c.HandleTWCCFeedback(pkts[0].(*rtcp.TransportLayerCC))
+
+ // receive time is relative to the first received packet
+ if got := c.packetTracker.getPacketInfoExisting(sns[3]).recvTime; got != 2000 {
+ t.Fatalf("receive time after reserved symbols = %d, want 2000", got)
+ }
+
+ acked, lost := 0, 0
+ for _, pg := range c.packetGroups {
+ acked += pg.acked.numPackets()
+ lost += pg.lost.numPackets()
+ }
+ if acked != 2 || lost != 1 {
+ t.Fatalf("feedback counts: acked=%d lost=%d, want 2 and 1", acked, lost)
+ }
+ })
+ }
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/packet_group.go b/internal/thirdparty/livekit/sendsidebwe/packet_group.go
new file mode 100644
index 000000000..b64727fdf
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/packet_group.go
@@ -0,0 +1,310 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "errors"
+ "time"
+
+ "github.com/livekit/protocol/logger"
+ "github.com/livekit/protocol/utils"
+ "go.uber.org/zap/zapcore"
+)
+
+// -------------------------------------------------------------
+
+var (
+ errGroupFinalized = errors.New("packet group is finalized")
+ errOldPacket = errors.New("packet is older than packet group start")
+)
+
+// -------------------------------------------------------------
+
+type PacketGroupConfig struct {
+ MinPackets int `yaml:"min_packets,omitempty"`
+ MaxWindowDuration time.Duration `yaml:"max_window_duration,omitempty"`
+}
+
+var (
+ defaultPacketGroupConfig = PacketGroupConfig{
+ MinPackets: 30,
+ MaxWindowDuration: 500 * time.Millisecond,
+ }
+)
+
+// -------------------------------------------------------------
+
+type stat struct {
+ numPackets int
+ numBytes int
+}
+
+func (s *stat) add(size int) {
+ s.numPackets++
+ s.numBytes += size
+}
+
+func (s *stat) remove(size int) {
+ s.numPackets--
+ s.numBytes -= size
+}
+
+func (s *stat) getNumPackets() int {
+ return s.numPackets
+}
+
+func (s *stat) getNumBytes() int {
+ return s.numBytes
+}
+
+func (s stat) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ e.AddInt("numPackets", s.numPackets)
+ e.AddInt("numBytes", s.numBytes)
+ return nil
+}
+
+// -------------------------------------------------------------
+
+type classStat struct {
+ primary stat
+ rtx stat
+ probe stat
+}
+
+func (c *classStat) add(size int, isRTX bool, isProbe bool) {
+ if isRTX {
+ c.rtx.add(size)
+ } else if isProbe {
+ c.probe.add(size)
+ } else {
+ c.primary.add(size)
+ }
+}
+
+func (c *classStat) remove(size int, isRTX bool, isProbe bool) {
+ if isRTX {
+ c.rtx.remove(size)
+ } else if isProbe {
+ c.probe.remove(size)
+ } else {
+ c.primary.remove(size)
+ }
+}
+
+func (c *classStat) numPackets() int {
+ return c.primary.getNumPackets() + c.rtx.getNumPackets() + c.probe.getNumPackets()
+}
+
+func (c *classStat) numBytes() int {
+ return c.primary.getNumBytes() + c.rtx.getNumBytes() + c.probe.getNumBytes()
+}
+
+func (c classStat) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ e.AddObject("primary", c.primary)
+ e.AddObject("rtx", c.rtx)
+ e.AddObject("probe", c.probe)
+ return nil
+}
+
+// -------------------------------------------------------------
+
+type packetGroupParams struct {
+ Config PacketGroupConfig
+ WeightedLoss WeightedLossConfig
+ Logger logger.Logger
+}
+
+type packetGroup struct {
+ params packetGroupParams
+
+ minSequenceNumber uint64
+ maxSequenceNumber uint64
+
+ minSendTime int64
+ maxSendTime int64
+
+ minRecvTime int64 // for information only
+ maxRecvTime int64 // for information only
+
+ acked classStat
+ lost classStat
+ snBitmap *utils.Bitmap[uint64]
+
+ aggregateSendDelta int64
+ aggregateRecvDelta int64
+ inheritedQueuingDelay int64
+
+ isFinalized bool
+}
+
+func newPacketGroup(params packetGroupParams, inheritedQueuingDelay int64) *packetGroup {
+ return &packetGroup{
+ params: params,
+ inheritedQueuingDelay: inheritedQueuingDelay,
+ snBitmap: utils.NewBitmap[uint64](params.Config.MinPackets),
+ }
+}
+
+func (p *packetGroup) Add(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) error {
+ if isLost {
+ return p.lostPacket(pi)
+ }
+
+ if err := p.inGroup(pi.sequenceNumber); err != nil {
+ return err
+ }
+
+ if p.minSequenceNumber == 0 || pi.sequenceNumber < p.minSequenceNumber {
+ p.minSequenceNumber = pi.sequenceNumber
+ }
+ p.maxSequenceNumber = max(p.maxSequenceNumber, pi.sequenceNumber)
+
+ if p.minSendTime == 0 || (pi.sendTime-sendDelta) < p.minSendTime {
+ p.minSendTime = pi.sendTime - sendDelta
+ }
+ p.maxSendTime = max(p.maxSendTime, pi.sendTime)
+
+ if p.minRecvTime == 0 || (pi.recvTime-recvDelta) < p.minRecvTime {
+ p.minRecvTime = pi.recvTime - recvDelta
+ }
+ p.maxRecvTime = max(p.maxRecvTime, pi.recvTime)
+
+ p.acked.add(int(pi.size), pi.isRTX, pi.isProbe)
+ if int(pi.sequenceNumber-p.minSequenceNumber) < p.snBitmap.Len() && p.snBitmap.IsSet(pi.sequenceNumber-p.minSequenceNumber) {
+ // an earlier packet reported as lost has been received
+ p.snBitmap.Clear(pi.sequenceNumber - p.minSequenceNumber)
+ p.lost.remove(int(pi.size), pi.isRTX, pi.isProbe)
+ }
+
+ // note that out-of-order deliveries will amplify the queueing delay.
+ // for e.g. a, b, c getting delivered as a, c, b.
+ // let us say packets are delivered with interval of `x`
+ // send delta aggregate will go up by x((a, c) = 2x + (c, b) -1x)
+ // recv delta aggregate will go up by 3x((a, c) = 2x + (c, b) 1x)
+ p.aggregateSendDelta += sendDelta
+ p.aggregateRecvDelta += recvDelta
+
+ if p.acked.numPackets() == p.params.Config.MinPackets || (pi.sendTime-p.minSendTime) > p.params.Config.MaxWindowDuration.Microseconds() {
+ p.isFinalized = true
+ }
+ return nil
+}
+
+func (p *packetGroup) lostPacket(pi *packetInfo) error {
+ if pi.recvTime != 0 {
+ // previously received packet, so not lost
+ return nil
+ }
+
+ if err := p.inGroup(pi.sequenceNumber); err != nil {
+ return err
+ }
+
+ if p.minSequenceNumber == 0 || pi.sequenceNumber < p.minSequenceNumber {
+ p.minSequenceNumber = pi.sequenceNumber
+ }
+ p.maxSequenceNumber = max(p.maxSequenceNumber, pi.sequenceNumber)
+ p.snBitmap.Set(pi.sequenceNumber - p.minSequenceNumber)
+
+ p.lost.add(int(pi.size), pi.isRTX, pi.isProbe)
+ return nil
+}
+
+func (p *packetGroup) MinSendTime() int64 {
+ return p.minSendTime
+}
+
+func (p *packetGroup) SendWindow() (int64, int64) {
+ return p.minSendTime, p.maxSendTime
+}
+
+func (p *packetGroup) PropagatedQueuingDelay() int64 {
+ if p.inheritedQueuingDelay+p.aggregateRecvDelta-p.aggregateSendDelta > 0 {
+ return p.inheritedQueuingDelay + p.aggregateRecvDelta - p.aggregateSendDelta
+ }
+
+ return max(0, p.aggregateRecvDelta-p.aggregateSendDelta)
+}
+
+func (p *packetGroup) FinalizedPropagatedQueuingDelay() (int64, bool) {
+ if !p.isFinalized {
+ return 0, false
+ }
+
+ return p.PropagatedQueuingDelay(), true
+}
+
+func (p *packetGroup) IsFinalized() bool {
+ return p.isFinalized
+}
+
+func (p *packetGroup) Traffic() *trafficStats {
+ return &trafficStats{
+ minSendTime: p.minSendTime,
+ maxSendTime: p.maxSendTime,
+ sendDelta: p.aggregateSendDelta,
+ recvDelta: p.aggregateRecvDelta,
+ ackedPackets: p.acked.numPackets(),
+ ackedBytes: p.acked.numBytes(),
+ lostPackets: p.lost.numPackets(),
+ lostBytes: p.lost.numBytes(),
+ }
+}
+
+func (p *packetGroup) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if p == nil {
+ return nil
+ }
+
+ e.AddUint64("minSequenceNumber", p.minSequenceNumber)
+ e.AddUint64("maxSequenceNumber", p.maxSequenceNumber)
+ e.AddObject("acked", p.acked)
+ e.AddObject("lost", p.lost)
+
+ e.AddInt64("minRecvTime", p.minRecvTime)
+ e.AddInt64("maxRecvTime", p.maxRecvTime)
+ recvDuration := time.Duration((p.maxRecvTime - p.minRecvTime) * 1000)
+ e.AddDuration("recvDuration", recvDuration)
+
+ recvBitrate := float64(0)
+ if recvDuration != 0 {
+ recvBitrate = float64(p.acked.numBytes()*8) / recvDuration.Seconds()
+ e.AddFloat64("recvBitrate", recvBitrate)
+ }
+
+ ts := newTrafficStats(trafficStatsParams{
+ Config: p.params.WeightedLoss,
+ Logger: p.params.Logger,
+ })
+ ts.Merge(p.Traffic())
+ e.AddObject("trafficStats", ts)
+ e.AddInt64("inheritedQueuingDelay", p.inheritedQueuingDelay)
+ e.AddInt64("propagatedQueuingDelay", p.PropagatedQueuingDelay())
+
+ e.AddBool("isFinalized", p.isFinalized)
+ return nil
+}
+
+func (p *packetGroup) inGroup(sequenceNumber uint64) error {
+ if p.isFinalized && sequenceNumber > p.maxSequenceNumber {
+ return errGroupFinalized
+ }
+
+ if sequenceNumber < p.minSequenceNumber {
+ return errOldPacket
+ }
+
+ return nil
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/packet_info.go b/internal/thirdparty/livekit/sendsidebwe/packet_info.go
new file mode 100644
index 000000000..730e2a7e6
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/packet_info.go
@@ -0,0 +1,45 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+ "go.uber.org/zap/zapcore"
+)
+
+type packetInfo struct {
+ sequenceNumber uint64
+ sendTime int64
+ recvTime int64
+ probeClusterId ccutils.ProbeClusterId
+ size uint16
+ isRTX bool
+ isProbe bool
+}
+
+func (pi *packetInfo) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if pi == nil {
+ return nil
+ }
+
+ e.AddUint64("sequenceNumber", pi.sequenceNumber)
+ e.AddInt64("sendTime", pi.sendTime)
+ e.AddInt64("recvTime", pi.recvTime)
+ e.AddUint32("probeClusterId", uint32(pi.probeClusterId))
+ e.AddUint16("size", pi.size)
+ e.AddBool("isRTX", pi.isRTX)
+ e.AddBool("isProbe", pi.isProbe)
+ return nil
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/packet_tracker.go b/internal/thirdparty/livekit/sendsidebwe/packet_tracker.go
new file mode 100644
index 000000000..a41e50fee
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/packet_tracker.go
@@ -0,0 +1,168 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "math/rand"
+ "sync"
+
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+ "github.com/livekit/protocol/logger"
+ "github.com/livekit/protocol/utils/mono"
+)
+
+// -------------------------------------------------------------------------------
+
+type packetTrackerParams struct {
+ Logger logger.Logger
+}
+
+type packetTracker struct {
+ params packetTrackerParams
+
+ lock sync.Mutex
+
+ sequenceNumber uint64
+
+ baseSendTime int64
+ packetInfos [2048]packetInfo
+
+ baseRecvTime int64
+ piLastRecv *packetInfo
+
+ probeClusterId ccutils.ProbeClusterId
+ probeMaxSequenceNumber uint64
+}
+
+func newPacketTracker(params packetTrackerParams) *packetTracker {
+ return &packetTracker{
+ params: params,
+ sequenceNumber: uint64(rand.Intn(1<<14)) + uint64(1<<15), // a random number in third quartile of sequence number space
+ }
+}
+
+func (p *packetTracker) RecordPacketSendAndGetSequenceNumber(
+ atMicro int64,
+ size int,
+ isRTX bool,
+ probeClusterId ccutils.ProbeClusterId,
+ isProbe bool,
+) uint16 {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ if p.baseSendTime == 0 {
+ p.baseSendTime = atMicro
+ }
+
+ pi := p.getPacketInfo(uint16(p.sequenceNumber))
+ *pi = packetInfo{
+ sequenceNumber: p.sequenceNumber,
+ sendTime: atMicro - p.baseSendTime,
+ size: uint16(size),
+ isRTX: isRTX,
+ probeClusterId: probeClusterId,
+ isProbe: isProbe,
+ }
+
+ p.sequenceNumber++
+
+ // extreme case of wrap around before receiving any feedback
+ if pi == p.piLastRecv {
+ p.piLastRecv = nil
+ }
+
+ if p.probeClusterId != ccutils.ProbeClusterIdInvalid && p.probeClusterId == pi.probeClusterId && pi.sequenceNumber > p.probeMaxSequenceNumber {
+ p.probeMaxSequenceNumber = pi.sequenceNumber
+ }
+
+ return uint16(pi.sequenceNumber)
+}
+
+func (p *packetTracker) BaseSendTimeThreshold(threshold int64) (int64, bool) {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ if p.baseSendTime == 0 {
+ return 0, false
+ }
+
+ return mono.UnixMicro() - p.baseSendTime - threshold, true
+}
+
+func (p *packetTracker) RecordPacketIndicationFromRemote(sn uint16, recvTime int64) (piRecv packetInfo, sendDelta, recvDelta int64) {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ pi := p.getPacketInfoExisting(sn)
+ if pi == nil {
+ return
+ }
+
+ if recvTime == 0 {
+ // maybe lost OR already received but reported lost in a later report
+ piRecv = *pi
+ return
+ }
+
+ if p.baseRecvTime == 0 {
+ p.baseRecvTime = recvTime
+ p.piLastRecv = pi
+ }
+
+ pi.recvTime = recvTime - p.baseRecvTime
+ piRecv = *pi
+ if p.piLastRecv != nil {
+ sendDelta, recvDelta = pi.sendTime-p.piLastRecv.sendTime, pi.recvTime-p.piLastRecv.recvTime
+ }
+ p.piLastRecv = pi
+ return
+}
+
+func (p *packetTracker) getPacketInfo(sn uint16) *packetInfo {
+ return &p.packetInfos[int(sn)%len(p.packetInfos)]
+}
+
+func (p *packetTracker) getPacketInfoExisting(sn uint16) *packetInfo {
+ pi := &p.packetInfos[int(sn)%len(p.packetInfos)]
+ if uint16(pi.sequenceNumber) == sn {
+ return pi
+ }
+
+ return nil
+}
+
+func (p *packetTracker) ProbeClusterStarting(probeClusterId ccutils.ProbeClusterId) {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ p.probeClusterId = probeClusterId
+}
+
+func (p *packetTracker) ProbeClusterDone(probeClusterId ccutils.ProbeClusterId) {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ if p.probeClusterId == probeClusterId {
+ p.probeClusterId = ccutils.ProbeClusterIdInvalid
+ }
+}
+
+func (p *packetTracker) ProbeMaxSequenceNumber() uint64 {
+ p.lock.Lock()
+ defer p.lock.Unlock()
+
+ return p.probeMaxSequenceNumber
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/probe_packet_group.go b/internal/thirdparty/livekit/sendsidebwe/probe_packet_group.go
new file mode 100644
index 000000000..8d0d1d72a
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/probe_packet_group.go
@@ -0,0 +1,143 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Modified by Piik: start probe timing at its first acknowledged packet.
+package sendsidebwe
+
+import (
+ "time"
+
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+ "github.com/livekit/protocol/logger"
+ "github.com/livekit/protocol/utils/mono"
+ "go.uber.org/zap/zapcore"
+)
+
+// -------------------------------------------------------------
+
+type ProbePacketGroupConfig struct {
+ PacketGroup PacketGroupConfig `yaml:"packet_group,omitempty"`
+
+ SettleWaitNumRTT uint32 `yaml:"settle_wait_num_rtt,omitempty"`
+ SettleWaitMin time.Duration `yaml:"settle_wait_min,omitempty"`
+ SettleWaitMax time.Duration `yaml:"settle_wait_max,omitempty"`
+}
+
+var (
+ // large numbers to treat a probe packet group as one
+ defaultProbePacketGroupConfig = ProbePacketGroupConfig{
+ PacketGroup: PacketGroupConfig{
+ MinPackets: 16384,
+ MaxWindowDuration: time.Minute,
+ },
+
+ SettleWaitNumRTT: 5,
+ SettleWaitMin: 250 * time.Millisecond,
+ SettleWaitMax: 5 * time.Second,
+ }
+)
+
+// -------------------------------------------------------------
+
+type probePacketGroupParams struct {
+ Config ProbePacketGroupConfig
+ WeightedLoss WeightedLossConfig
+ Logger logger.Logger
+}
+
+type probePacketGroup struct {
+ params probePacketGroupParams
+ pci ccutils.ProbeClusterInfo
+ *packetGroup
+ maxSequenceNumber uint64
+ doneAt time.Time
+}
+
+func newProbePacketGroup(params probePacketGroupParams, pci ccutils.ProbeClusterInfo) *probePacketGroup {
+ return &probePacketGroup{
+ params: params,
+ pci: pci,
+ packetGroup: newPacketGroup(
+ packetGroupParams{
+ Config: params.Config.PacketGroup,
+ WeightedLoss: params.WeightedLoss,
+ Logger: params.Logger,
+ },
+ 0,
+ ),
+ }
+}
+
+func (p *probePacketGroup) ProbeClusterDone(pci ccutils.ProbeClusterInfo) {
+ if p.pci.Id != pci.Id {
+ return
+ }
+
+ p.pci.Result = pci.Result
+ p.doneAt = mono.Now()
+}
+
+func (p *probePacketGroup) ProbeClusterInfo() ccutils.ProbeClusterInfo {
+ return p.pci
+}
+
+func (p *probePacketGroup) MaybeFinalizeProbe(maxSequenceNumber uint64, rtt float64) (ccutils.ProbeClusterInfo, bool) {
+ if p.doneAt.IsZero() {
+ return ccutils.ProbeClusterInfoInvalid, false
+ }
+
+ if maxSequenceNumber != 0 && p.maxSequenceNumber >= maxSequenceNumber {
+ return p.pci, true
+ }
+
+ settleWait := time.Duration(float64(p.params.Config.SettleWaitNumRTT) * rtt * float64(time.Second))
+ if settleWait < p.params.Config.SettleWaitMin {
+ settleWait = p.params.Config.SettleWaitMin
+ }
+ if settleWait > p.params.Config.SettleWaitMax {
+ settleWait = p.params.Config.SettleWaitMax
+ }
+ if time.Since(p.doneAt) < settleWait {
+ return ccutils.ProbeClusterInfoInvalid, false
+ }
+
+ return p.pci, true
+}
+
+func (p *probePacketGroup) Add(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) error {
+ if pi.probeClusterId != p.pci.Id {
+ return nil
+ }
+
+ p.maxSequenceNumber = max(p.maxSequenceNumber, pi.sequenceNumber)
+
+ // The first received probe packet starts this measurement window.
+ // The transport's previous packet may precede it by an idle interval.
+ if !isLost && p.acked.numPackets() == 0 {
+ sendDelta, recvDelta = 0, 0
+ }
+ return p.packetGroup.Add(pi, sendDelta, recvDelta, isLost)
+}
+
+func (p *probePacketGroup) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if p == nil {
+ return nil
+ }
+
+ e.AddObject("pci", p.pci)
+ e.AddObject("packetGroup", p.packetGroup)
+ e.AddUint64("maxSequenceNumber", p.maxSequenceNumber)
+ e.AddTime("doneAt", p.doneAt)
+ return nil
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/send_side_bwe.go b/internal/thirdparty/livekit/sendsidebwe/send_side_bwe.go
new file mode 100644
index 000000000..df631f27e
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/send_side_bwe.go
@@ -0,0 +1,151 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "time"
+
+ "github.com/livekit/livekit-server/pkg/sfu/bwe"
+ "github.com/livekit/livekit-server/pkg/sfu/ccutils"
+ "github.com/livekit/protocol/logger"
+ "github.com/pion/rtcp"
+)
+
+var _ bwe.BWE = (*SendSideBWE)(nil)
+
+//
+// Based on a simplified/modified version of JitterPath paper
+// (https://homepage.iis.sinica.edu.tw/papers/lcs/2114-F.pdf)
+//
+// TWCC feedback is used to calculate delta one-way-delay.
+// It is accumulated/propagated to determine in which region
+// groups of packets are operating in.
+//
+// In simplified terms,
+// o JQR (Join Queuing Region) is when channel is congested.
+// o DQR (Disjoint Queuing Region) is when channel is not.
+//
+// Packets are grouped and thresholds applied to smooth over
+// small variations. For example, in the paper,
+// if propagated_queuing_delay + delta_one_way_delay > 0 {
+// possibly_operating_in_jqr
+// }
+// But, in this implementation it is checked at packet group level,
+// i. e. using queuing delay and aggregated delta one-way-delay of
+// the group and a minimum value threshold is applied before declaring
+// that a group is in JQR.
+//
+// There is also hysteresis to make transisitons smoother, i.e. if the
+// metric is above a certain threshold, it is JQR and it is DQR only if it
+// is below a certain value and the gap in between those two thresholds
+// are treated as interdeterminate groups.
+//
+
+// ---------------------------------------------------------------------------
+
+type SendSideBWEConfig struct {
+ CongestionDetector CongestionDetectorConfig `yaml:"congestion_detector,omitempty"`
+}
+
+var (
+ DefaultSendSideBWEConfig = SendSideBWEConfig{
+ CongestionDetector: defaultCongestionDetectorConfig,
+ }
+)
+
+// ---------------------------------------------------------------------------
+
+type SendSideBWEParams struct {
+ Config SendSideBWEConfig
+ Logger logger.Logger
+}
+
+type SendSideBWE struct {
+ bwe.NullBWE
+
+ params SendSideBWEParams
+
+ *congestionDetector
+}
+
+func NewSendSideBWE(params SendSideBWEParams) *SendSideBWE {
+ return &SendSideBWE{
+ params: params,
+ congestionDetector: newCongestionDetector(congestionDetectorParams{
+ Config: params.Config.CongestionDetector,
+ Logger: params.Logger,
+ }),
+ }
+}
+
+func (s *SendSideBWE) Type() bwe.BWEType {
+ return bwe.BWETypeSendSide
+}
+
+func (s *SendSideBWE) SetBWEListener(bweListener bwe.BWEListener) {
+ s.congestionDetector.SetBWEListener(bweListener)
+}
+
+func (s *SendSideBWE) Reset() {
+ s.congestionDetector.Reset()
+}
+
+func (s *SendSideBWE) RecordPacketSendAndGetSequenceNumber(
+ atMicro int64,
+ size int,
+ isRTX bool,
+ probeClusterId ccutils.ProbeClusterId,
+ isProbe bool,
+) uint16 {
+ return s.congestionDetector.RecordPacketSendAndGetSequenceNumber(atMicro, size, isRTX, probeClusterId, isProbe)
+}
+
+func (s *SendSideBWE) HandleTWCCFeedback(report *rtcp.TransportLayerCC) {
+ s.congestionDetector.HandleTWCCFeedback(report)
+}
+
+func (s *SendSideBWE) UpdateRTT(rtt float64) {
+ s.congestionDetector.UpdateRTT(rtt)
+}
+
+func (s *SendSideBWE) CongestionState() bwe.CongestionState {
+ return s.congestionDetector.CongestionState()
+}
+
+func (s *SendSideBWE) CanProbe() bool {
+ return s.congestionDetector.CanProbe()
+}
+
+func (s *SendSideBWE) ProbeDuration() time.Duration {
+ return s.congestionDetector.ProbeDuration()
+}
+
+func (s *SendSideBWE) ProbeClusterStarting(pci ccutils.ProbeClusterInfo) {
+ s.congestionDetector.ProbeClusterStarting(pci)
+}
+
+func (s *SendSideBWE) ProbeClusterDone(pci ccutils.ProbeClusterInfo) {
+ s.congestionDetector.ProbeClusterDone(pci)
+}
+
+func (s *SendSideBWE) ProbeClusterIsGoalReached() bool {
+ return s.congestionDetector.ProbeClusterIsGoalReached()
+}
+
+func (s *SendSideBWE) ProbeClusterFinalize() (ccutils.ProbeSignal, int64, bool) {
+ return s.congestionDetector.ProbeClusterFinalize()
+}
+
+// ------------------------------------------------
diff --git a/internal/thirdparty/livekit/sendsidebwe/traffic_stats.go b/internal/thirdparty/livekit/sendsidebwe/traffic_stats.go
new file mode 100644
index 000000000..f1c07d54e
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/traffic_stats.go
@@ -0,0 +1,191 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "math"
+ "time"
+
+ "github.com/livekit/protocol/logger"
+ "go.uber.org/zap/zapcore"
+)
+
+// -----------------------------------------------------------
+
+type WeightedLossConfig struct {
+ MinDurationForLossValidity time.Duration `yaml:"min_duration_for_loss_validity,omitempty"`
+ BaseDuration time.Duration `yaml:"base_duration,omitempty"`
+ BasePPS int `yaml:"base_pps,omitempty"`
+ LossPenaltyFactor float64 `yaml:"loss_penalty_factor,omitempty"`
+}
+
+var (
+ defaultWeightedLossConfig = WeightedLossConfig{
+ MinDurationForLossValidity: 100 * time.Millisecond,
+ BaseDuration: 500 * time.Millisecond,
+ BasePPS: 30,
+ LossPenaltyFactor: 0.25,
+ }
+)
+
+// -----------------------------------------------------------
+
+type trafficStatsParams struct {
+ Config WeightedLossConfig
+ Logger logger.Logger
+}
+
+type trafficStats struct {
+ params trafficStatsParams
+
+ minSendTime int64
+ maxSendTime int64
+ sendDelta int64
+ recvDelta int64
+ ackedPackets int
+ ackedBytes int
+ lostPackets int
+ lostBytes int
+}
+
+func newTrafficStats(params trafficStatsParams) *trafficStats {
+ return &trafficStats{
+ params: params,
+ }
+}
+
+func (ts *trafficStats) Merge(rhs *trafficStats) {
+ if ts.minSendTime == 0 || rhs.minSendTime < ts.minSendTime {
+ ts.minSendTime = rhs.minSendTime
+ }
+ if rhs.maxSendTime > ts.maxSendTime {
+ ts.maxSendTime = rhs.maxSendTime
+ }
+ ts.sendDelta += rhs.sendDelta
+ ts.recvDelta += rhs.recvDelta
+ ts.ackedPackets += rhs.ackedPackets
+ ts.ackedBytes += rhs.ackedBytes
+ ts.lostPackets += rhs.lostPackets
+ ts.lostBytes += rhs.lostBytes
+}
+
+func (ts *trafficStats) NumBytes() int {
+ return ts.ackedBytes + ts.lostBytes
+}
+
+func (ts *trafficStats) Duration() int64 {
+ return ts.maxSendTime - ts.minSendTime
+}
+
+func (ts *trafficStats) AcknowledgedBitrate() int64 {
+ duration := ts.Duration()
+ if duration == 0 {
+ return 0
+ }
+
+ ackedBitrate := float64(ts.ackedBytes) * 8 * 1e6 / float64(ts.Duration())
+ return int64(ackedBitrate * ts.CapturedTrafficRatio())
+}
+
+func (ts *trafficStats) CapturedTrafficRatio() float64 {
+ if ts.recvDelta == 0 {
+ return 0.0
+ }
+
+ // apply a penalty for lost packets,
+ // the rationale being packet dropping is a strategy to relieve congestion
+ // and if they were not dropped, they would have increased queuing delay,
+ // as it is not possible to know the reason for the losses,
+ // apply a small penalty to receive delta aggregate to simulate those packets
+ // building up queuing delay.
+ return min(1.0, float64(ts.sendDelta)/float64(ts.recvDelta+ts.lossPenalty()))
+}
+
+func (ts *trafficStats) WeightedLoss() float64 {
+ durationMicro := ts.Duration()
+ if time.Duration(durationMicro*1000) < ts.params.Config.MinDurationForLossValidity {
+ return 0.0
+ }
+
+ totalPackets := float64(ts.lostPackets + ts.ackedPackets)
+ pps := totalPackets * 1e6 / float64(durationMicro)
+
+ // longer duration, i. e. more time resolution, lower pps is acceptable as the measurement is more stable
+ deltaDuration := time.Duration(durationMicro*1000) - ts.params.Config.BaseDuration
+ if deltaDuration < 0 {
+ deltaDuration = 0
+ }
+ threshold := math.Exp(-deltaDuration.Seconds()) * float64(ts.params.Config.BasePPS)
+ if pps < threshold {
+ return 0.0
+ }
+
+ lossRatio := float64(0.0)
+ if totalPackets != 0 {
+ lossRatio = float64(ts.lostPackets) / totalPackets
+ }
+
+ // Log10 is used to give higher weight for the same loss ratio at higher packet rates,
+ // for e.g.
+ // - 10% loss at 20 pps = 0.1 * log10(20) = 0.130
+ // - 10% loss at 100 pps = 0.1 * log10(100) = 0.2
+ // - 10% loss at 1000 pps = 0.1 * log10(1000) = 0.3
+ return lossRatio * math.Log10(pps)
+}
+
+func (ts *trafficStats) lossPenalty() int64 {
+ return int64(float64(ts.recvDelta) * ts.WeightedLoss() * ts.params.Config.LossPenaltyFactor)
+}
+
+func (ts *trafficStats) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if ts == nil {
+ return nil
+ }
+
+ e.AddInt64("minSendTime", ts.minSendTime)
+ e.AddInt64("maxSendTime", ts.maxSendTime)
+ duration := time.Duration(ts.Duration() * 1000)
+ e.AddDuration("duration", duration)
+
+ e.AddInt("ackedPackets", ts.ackedPackets)
+ e.AddInt("ackedBytes", ts.ackedBytes)
+ e.AddInt("lostPackets", ts.lostPackets)
+ e.AddInt("lostBytes", ts.lostBytes)
+
+ bitrate := float64(0)
+ if duration != 0 {
+ bitrate = float64(ts.ackedBytes*8) / duration.Seconds()
+ e.AddFloat64("bitrate", bitrate)
+ }
+
+ e.AddInt64("sendDelta", ts.sendDelta)
+ e.AddInt64("recvDelta", ts.recvDelta)
+ e.AddInt64("groupDelay", ts.recvDelta-ts.sendDelta)
+
+ totalPackets := ts.lostPackets + ts.ackedPackets
+ if duration != 0 {
+ e.AddFloat64("pps", float64(totalPackets)/duration.Seconds())
+ }
+ if (totalPackets) != 0 {
+ e.AddFloat64("rawLoss", float64(ts.lostPackets)/float64(totalPackets))
+ }
+ e.AddFloat64("weightedLoss", ts.WeightedLoss())
+ e.AddInt64("lossPenalty", ts.lossPenalty())
+
+ capturedTrafficRatio := ts.CapturedTrafficRatio()
+ e.AddFloat64("capturedTrafficRatio", capturedTrafficRatio)
+ e.AddFloat64("estimatedAvailableChannelCapacity", bitrate*capturedTrafficRatio)
+ return nil
+}
diff --git a/internal/thirdparty/livekit/sendsidebwe/twcc_feedback.go b/internal/thirdparty/livekit/sendsidebwe/twcc_feedback.go
new file mode 100644
index 000000000..2b2c843e8
--- /dev/null
+++ b/internal/thirdparty/livekit/sendsidebwe/twcc_feedback.go
@@ -0,0 +1,127 @@
+// Copyright 2023 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sendsidebwe
+
+import (
+ "time"
+
+ "github.com/livekit/protocol/logger"
+ "github.com/pion/rtcp"
+ "go.uber.org/zap/zapcore"
+)
+
+// ------------------------------------------------------
+
+const (
+ cOutlierReportFactor = 3
+ cEstimatedFeedbackIntervalAlpha = float64(0.9)
+
+ cReferenceTimeMask = (1 << 24) - 1
+ cReferenceTimeResolution = 64 // 64 ms
+)
+
+// ------------------------------------------------------
+
+type twccFeedbackParams struct {
+ Logger logger.Logger
+}
+
+type twccFeedback struct {
+ params twccFeedbackParams
+
+ lastFeedbackTime time.Time
+ estimatedFeedbackInterval time.Duration
+ numReports int
+ numReportsOutOfOrder int
+
+ highestFeedbackCount uint8
+
+ cycles int64
+ highestReferenceTime uint32
+}
+
+func newTWCCFeedback(params twccFeedbackParams) *twccFeedback {
+ return &twccFeedback{
+ params: params,
+ }
+}
+
+func (t *twccFeedback) ProcessReport(report *rtcp.TransportLayerCC, at time.Time) (int64, bool) {
+ t.numReports++
+ if t.lastFeedbackTime.IsZero() {
+ t.lastFeedbackTime = at
+ t.highestReferenceTime = report.ReferenceTime
+ t.highestFeedbackCount = report.FbPktCount
+ return (t.cycles + int64(report.ReferenceTime)) * cReferenceTimeResolution * 1000, false
+ }
+
+ isOutOfOrder := false
+ if (report.FbPktCount - t.highestFeedbackCount) > (1 << 7) {
+ t.numReportsOutOfOrder++
+ isOutOfOrder = true
+ t.params.Logger.Infow(
+ "send side bwe: received out-of-order feedback report",
+ "highestFeedbackCount", t.highestFeedbackCount,
+ "fbPktCount", report.FbPktCount,
+ "numReportsOutOfOrder", t.numReportsOutOfOrder,
+ )
+ }
+
+ // reference time wrap around handling
+ var referenceTime int64
+ if (report.ReferenceTime-t.highestReferenceTime)&cReferenceTimeMask < (1 << 23) {
+ if report.ReferenceTime < t.highestReferenceTime {
+ t.cycles += (1 << 24)
+ }
+ t.highestReferenceTime = report.ReferenceTime
+ referenceTime = t.cycles + int64(report.ReferenceTime)
+ } else {
+ cycles := t.cycles
+ if report.ReferenceTime > t.highestReferenceTime && cycles >= (1<<24) {
+ cycles -= (1 << 24)
+ }
+ referenceTime = cycles + int64(report.ReferenceTime)
+ }
+
+ if !isOutOfOrder {
+ sinceLast := at.Sub(t.lastFeedbackTime)
+ if t.estimatedFeedbackInterval == 0 {
+ t.estimatedFeedbackInterval = sinceLast
+ } else {
+ // filter out outliers from estimate
+ if sinceLast > t.estimatedFeedbackInterval/cOutlierReportFactor && sinceLast < cOutlierReportFactor*t.estimatedFeedbackInterval {
+ // smoothed version of inter feedback interval
+ t.estimatedFeedbackInterval = time.Duration(cEstimatedFeedbackIntervalAlpha*float64(t.estimatedFeedbackInterval) + (1.0-cEstimatedFeedbackIntervalAlpha)*float64(sinceLast))
+ }
+ }
+ t.lastFeedbackTime = at
+ t.highestFeedbackCount = report.FbPktCount
+ }
+
+ return referenceTime * cReferenceTimeResolution * 1000, isOutOfOrder
+}
+
+func (t *twccFeedback) MarshalLogObject(e zapcore.ObjectEncoder) error {
+ if t == nil {
+ return nil
+ }
+
+ e.AddTime("lastFeedbackTime", t.lastFeedbackTime)
+ e.AddDuration("estimatedFeedbackInterval", t.estimatedFeedbackInterval)
+ e.AddInt("numReports", t.numReports)
+ e.AddInt("numReportsOutOfOrder", t.numReportsOutOfOrder)
+ e.AddInt64("cycles", t.cycles/(1<<24))
+ return nil
+}
diff --git a/native/capture/darwin/README.md b/native/capture/darwin/README.md
index 610f63522..a72a03dce 100644
--- a/native/capture/darwin/README.md
+++ b/native/capture/darwin/README.md
@@ -14,13 +14,20 @@ machine's architecture:
```sh
sh native/capture/darwin/build.sh /outside/repository/build
+/outside/repository/build/piik-capture --geometry-self-test
/outside/repository/build/piik-capture --self-test
/outside/repository/build/piik-capture --probe
```
-`--self-test` encodes one in-memory 420v frame through the same hardware-only
-VideoToolbox path and requires a constrained-baseline SPS/PPS/IDR. It needs no
-Screen Recording permission. Real source selection, capture, and recovery still
+`--geometry-self-test` checks content cropping, Retina scaling and rejection of
+invalid frame geometry, plus H.264 format/aperture admission, without requiring
+a hardware encoder. Go validates every raw SPS's coded size and reference budget
+before sending encoded input to the helper; CoreMedia can normalize SPS cropping
+in its format description. `--self-test`
+encodes successive landscape, portrait, ultrawide and small 420v buffers through
+one output worker, requiring the fitted dimensions and a constrained-baseline
+SPS/PPS/IDR after each size change. Neither needs Screen Recording permission.
+Real source selection, window resizing, display-scale changes and recovery still
require a physical macOS gate.
The first source-list or capture request is subject to the normal macOS Screen
diff --git a/native/capture/darwin/main.swift b/native/capture/darwin/main.swift
index fc4986a7c..9e2fdc188 100644
--- a/native/capture/darwin/main.swift
+++ b/native/capture/darwin/main.swift
@@ -15,8 +15,15 @@ private let height = 720
private let frameRate: Int32 = 30
private let bitrate = 3_000_000
private let maxPayloadBytes = 4_194_304
+private let maxVideoDimension = 8192
+private let maxVideoPixels = 36_864 * 16 * 16
+private let maxVideoFramerate: Int32 = 240
+private let maxVideoBitrate = 240_000_000
+private let maxCodedVideoDimension = 8192
+private let maxCodedVideoPixels = 36_864 * 16 * 16
private let supportedH264Levels: Set = [
- 0x1e, 0x1f, 0x20, 0x28, 0x29, 0x2a, 0x32, 0x33,
+ 0x0a, 0x0b, 0x0c, 0x0d, 0x14, 0x15, 0x16,
+ 0x1e, 0x1f, 0x20, 0x28, 0x29, 0x2a, 0x32, 0x33, 0x34,
]
private enum DegradationPreference: String {
@@ -45,6 +52,61 @@ private struct OutputProfile: Codable {
var frameInterval: CMTime { CMTime(value: 1, timescale: fps) }
}
+// Keep the captured pixels, their clean aperture and their unscaled size together.
+// ScreenCaptureKit may place scaled window content inside a larger fixed surface.
+private struct VideoImage {
+ let buffer: CVPixelBuffer
+ let size: CGSize
+
+ init(_ buffer: CVPixelBuffer) {
+ self.buffer = buffer
+ self.size = CVImageBufferGetCleanRect(buffer).size
+ }
+
+ init?(_ buffer: CVPixelBuffer, attachments: [SCStreamFrameInfo: Any]) {
+ guard let dictionary = attachments[.contentRect] as? [String: Any],
+ let rect = CGRect(dictionaryRepresentation: dictionary as CFDictionary),
+ let scale = attachments[.scaleFactor] as? CGFloat,
+ let contentScale = attachments[.contentScale] as? CGFloat,
+ scale.isFinite, scale > 0, contentScale.isFinite, contentScale > 0 else {
+ return nil
+ }
+ let crop = CGRect(x: rect.minX * scale, y: rect.minY * scale,
+ width: rect.width * scale, height: rect.height * scale)
+ let bounds = CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(buffer),
+ height: CVPixelBufferGetHeight(buffer))
+ guard crop.minX.isFinite, crop.minY.isFinite,
+ crop.width.isFinite, crop.height.isFinite,
+ crop.width > 0, crop.height > 0, bounds.contains(crop) else { return nil }
+ let size = CGSize(width: crop.width / contentScale, height: crop.height / contentScale)
+ guard size.width.isFinite, size.height.isFinite else { return nil }
+ // Set once before sharing the retained buffer with the output workers.
+ let aperture: [String: Any] = [
+ kCVImageBufferCleanApertureWidthKey as String: crop.width,
+ kCVImageBufferCleanApertureHeightKey as String: crop.height,
+ kCVImageBufferCleanApertureHorizontalOffsetKey as String: crop.midX - bounds.midX,
+ kCVImageBufferCleanApertureVerticalOffsetKey as String: crop.midY - bounds.midY,
+ ]
+ CVBufferSetAttachment(buffer, kCVImageBufferCleanApertureKey,
+ aperture as CFDictionary, .shouldPropagate)
+ self.buffer = buffer
+ self.size = size
+ }
+
+ func fitted(to profile: OutputProfile) throws -> OutputProfile {
+ guard size.width.isFinite, size.height.isFinite, size.width > 0, size.height > 0 else {
+ throw CaptureFailure(description: "invalid video content size")
+ }
+ let scale = min(1, CGFloat(profile.width) / size.width,
+ CGFloat(profile.height) / size.height)
+ return OutputProfile(
+ width: max(2, Int((size.width * scale / 2).rounded(.down)) * 2),
+ height: max(2, Int((size.height * scale / 2).rounded(.down)) * 2),
+ fps: profile.fps, bitrate: profile.bitrate
+ )
+ }
+}
+
private enum CaptureControl {
case keyFrame(Int)
case active(Int, Bool)
@@ -218,21 +280,8 @@ private func shareableWindows(_ content: SCShareableContent) -> [(SCWindow, Capt
}
}
-private func hardwareH264Available() -> Bool {
- do {
- let encoder = try HardwareEncoder(
- writer: ProtocolWriter(),
- done: StopSignal()
- )
- encoder.close()
- return true
- } catch {
- return false
- }
-}
-
private func probe() throws {
- let encoders = hardwareH264Available()
+ let encoders = HardwareEncoder.isAvailable()
? [EncoderProbe(
index: 0,
name: "VideoToolbox H.264",
@@ -444,13 +493,21 @@ private func h264ParameterSets(_ format: CMFormatDescription) throws -> [Data] {
result.append(Data(bytes: pointer, count: size))
}
let sps = result[0]
- guard sps.count >= 4, sps[1] == 0x42, sps[2] == 0xc0,
- supportedH264Levels.contains(sps[3]) else {
+ guard sps.count >= 4, supportedH264Profile(sps[1], sps[2], sps[3]) else {
throw CaptureFailure(description: "H.264 profile is outside the product envelope")
}
return result
}
+private func supportedH264Profile(_ profile: UInt8, _ constraints: UInt8, _ level: UInt8) -> Bool {
+ // RFC 6184 Table 5: equivalent Constrained Baseline tools, as in the Go reader.
+ supportedH264Levels.contains(level) && (
+ (profile == 0x42 && constraints & 0x4f == 0x40) ||
+ (profile == 0x4d && constraints & 0x8f == 0x80) ||
+ (profile == 0x58 && constraints & 0xcf == 0xc0)
+ )
+}
+
private func annexB(_ sample: CMSampleBuffer, keyFrame: Bool) throws -> (payload: Data, recovery: Bool) {
guard let block = CMSampleBufferGetDataBuffer(sample),
let format = CMSampleBufferGetFormatDescription(sample) else {
@@ -520,7 +577,7 @@ private func h264ProfileLevelID(_ payload: Data) -> String? {
private final class HardwareEncoder {
private struct Input {
- let image: CVImageBuffer
+ let image: VideoImage
let timestamp: CMTime
}
@@ -540,9 +597,11 @@ private final class HardwareEncoder {
private var currentBitrate: Int
private var lastFrameBucket: Int64?
private let sourceFrameRate: Int32
+ private let preference: DegradationPreference
private let profile: OutputProfile
+ private var output: OutputProfile?
private let layer: UInt8
- private let onActive: (String) throws -> Void
+ private let onActive: (String, OutputProfile) throws -> Void
private let onFailure: (Error) -> Void
init(
@@ -551,9 +610,9 @@ private final class HardwareEncoder {
profile sourceProfile: VideoProfile = defaultVideoProfile,
output: OutputProfile? = nil,
layer: UInt8 = 0,
- onActive: @escaping (String) throws -> Void = { _ in },
+ onActive: @escaping (String, OutputProfile) throws -> Void = { _, _ in },
onFailure: ((Error) -> Void)? = nil
- ) throws {
+ ) {
self.writer = writer
let profile = output ?? OutputProfile(
width: sourceProfile.width, height: sourceProfile.height,
@@ -561,12 +620,40 @@ private final class HardwareEncoder {
)
self.profile = profile
self.sourceFrameRate = sourceProfile.frameRate
+ self.preference = sourceProfile.preference
self.layer = layer
self.onActive = onActive
self.onFailure = onFailure ?? { done.signal($0) }
self.queue = DispatchQueue(label: "piik.capture.encoder.\(layer)")
desiredBitrate = profile.bitrate
currentBitrate = profile.bitrate
+ }
+
+ static func isAvailable() -> Bool {
+ let encoder = HardwareEncoder(writer: ProtocolWriter(), done: StopSignal())
+ defer { encoder.close() }
+ do {
+ _ = try encoder.queue.sync {
+ try encoder.prepare(encoder.profile, bitrate: encoder.profile.bitrate)
+ }
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ // Called only by the encoder queue after the previous frame has completed.
+ // A size change replaces this output's codec, not its capture or media route.
+ private func prepare(_ requested: OutputProfile, bitrate: Int) throws -> Bool {
+ if let output, output.width == requested.width, output.height == requested.height {
+ return false
+ }
+ if let session { VTCompressionSessionInvalidate(session) }
+ if let transfer { VTPixelTransferSessionInvalidate(transfer) }
+ session = nil
+ transfer = nil
+ output = nil
+ let profile = requested
let specification = [
kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder as String: true,
] as CFDictionary
@@ -601,20 +688,32 @@ private final class HardwareEncoder {
try set(kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse)
try set(kVTCompressionPropertyKey_ProfileLevel,
kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel)
- try set(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: profile.bitrate))
+ try set(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: bitrate))
try set(kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: profile.fps))
try set(kVTCompressionPropertyKey_MaxKeyFrameInterval, NSNumber(value: profile.fps * 2))
- if sourceProfile.preference != .balanced {
+ if preference != .balanced {
try? set(
kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality,
- sourceProfile.preference == .framerate ? kCFBooleanTrue : kCFBooleanFalse
+ preference == .framerate ? kCFBooleanTrue : kCFBooleanFalse
)
}
try require(VTPixelTransferSessionCreate(
allocator: nil, pixelTransferSessionOut: &transfer
), "videotoolbox-scaler-create")
+ guard let transfer else {
+ throw CaptureFailure(description: "VideoToolbox returned no scaler")
+ }
+ try require(VTSessionSetProperty(transfer,
+ key: kVTPixelTransferPropertyKey_ScalingMode,
+ value: kVTScalingMode_CropSourceToCleanAperture), "videotoolbox-scale-mode")
try require(VTCompressionSessionPrepareToEncodeFrames(created),
"videotoolbox-prepare")
+ output = profile
+ currentBitrate = bitrate
+ lock.lock()
+ decodable = false
+ lock.unlock()
+ return true
} catch {
VTCompressionSessionInvalidate(created)
if let transfer { VTPixelTransferSessionInvalidate(transfer) }
@@ -644,7 +743,7 @@ private final class HardwareEncoder {
lock.unlock()
}
- func encode(_ image: CVImageBuffer, timestamp: CMTime) {
+ func encode(_ image: VideoImage, timestamp: CMTime) {
lock.lock()
guard !closed, !failed else {
lock.unlock()
@@ -676,18 +775,22 @@ private final class HardwareEncoder {
return
}
pending = nil
- let force = forceKeyFrame
+ var force = forceKeyFrame
forceKeyFrame = false
let bitrate = desiredBitrate
encodingTimestamp = input.timestamp
lock.unlock()
- guard let session else { return }
do {
+ let output = try input.image.fitted(to: profile)
+ if try prepare(output, bitrate: bitrate) { force = true }
+ guard let session else {
+ throw CaptureFailure(description: "VideoToolbox encoder is unavailable")
+ }
if currentBitrate != bitrate {
try set(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: bitrate))
currentBitrate = bitrate
}
- let image = try scaled(input.image)
+ let image = try scaled(input.image.buffer, output: output)
let properties = force
? [kVTEncodeFrameOptionKey_ForceKeyFrame as String: true] as CFDictionary
: nil
@@ -729,7 +832,7 @@ private final class HardwareEncoder {
throw CaptureFailure(description: "invalid H.264 timestamp")
}
try writer.writeH264(
- payload, keyFrame: keyFrame, layer: layer, profile: profile,
+ payload, keyFrame: keyFrame, layer: layer, profile: output,
timestamp: UInt64(CMTimeConvertScale(
pts, timescale: 10_000_000, method: .roundTowardZero
).value),
@@ -739,7 +842,7 @@ private final class HardwareEncoder {
guard let profileLevelId = h264ProfileLevelID(payload) else {
throw CaptureFailure(description: "H.264 key frame has no SPS profile")
}
- try onActive(profileLevelId)
+ try onActive(profileLevelId, output)
}
} catch {
if force { requestKeyFrame() }
@@ -767,9 +870,11 @@ private final class HardwareEncoder {
if current { queue.async { self.encodePending() } }
}
- private func scaled(_ image: CVImageBuffer) throws -> CVPixelBuffer {
- if CVPixelBufferGetWidth(image) == profile.width &&
- CVPixelBufferGetHeight(image) == profile.height {
+ private func scaled(_ image: CVImageBuffer, output: OutputProfile) throws -> CVPixelBuffer {
+ let bounds = CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(image),
+ height: CVPixelBufferGetHeight(image))
+ if Int(bounds.width) == output.width && Int(bounds.height) == output.height &&
+ CVImageBufferGetCleanRect(image) == bounds {
return image
}
guard let session, let transfer,
@@ -855,22 +960,18 @@ private final class EncoderGroup {
try writer.writeStatus(StartingStatus(outputs: outputs))
if !encoded {
for index in 0...originalOutput { setActive(index, true) }
- if encoders[originalOutput] == nil {
- close()
- throw CaptureFailure(description: "original hardware video output is unavailable")
- }
}
}
- private func reportActive(_ profileLevelId: String) throws {
+ private func reportActive(_ profileLevelId: String, output: OutputProfile) throws {
lock.lock()
let publish = !active
active = true
lock.unlock()
if publish {
try writer.writeStatus(ActiveStatus(
- profileLevelId: profileLevelId, width: profile.width,
- height: profile.height, fps: profile.frameRate, outputs: outputs
+ profileLevelId: profileLevelId, width: output.width,
+ height: output.height, fps: profile.frameRate, outputs: outputs
))
}
}
@@ -888,7 +989,7 @@ private final class EncoderGroup {
if allFailed || startupFailed { done.signal(error) }
}
- func encode(_ image: CVImageBuffer, timestamp: CMTime) throws {
+ func encode(_ image: VideoImage, timestamp: CMTime) throws {
lifecycle.lock()
defer { lifecycle.unlock() }
if closed { return }
@@ -926,21 +1027,17 @@ private final class EncoderGroup {
lock.lock()
failedLayers.remove(layer)
lock.unlock()
- do {
- let encoder = try HardwareEncoder(
- writer: writer, done: done, profile: profile,
- output: outputs[index], layer: layer,
- onActive: { [weak self] profileLevelId in
- guard let self, index == self.originalOutput else { return }
- try self.reportActive(profileLevelId)
- },
- onFailure: { [weak self] in self?.unavailable(layer, $0) }
- )
- encoder.setBitrate(bitrates[index])
- encoders[index] = encoder
- } catch {
- unavailable(layer, error)
- }
+ let encoder = HardwareEncoder(
+ writer: writer, done: done, profile: profile,
+ output: outputs[index], layer: layer,
+ onActive: { [weak self] profileLevelId, output in
+ guard let self, index == self.originalOutput else { return }
+ try self.reportActive(profileLevelId, output: output)
+ },
+ onFailure: { [weak self] in self?.unavailable(layer, $0) }
+ )
+ encoder.setBitrate(bitrates[index])
+ encoders[index] = encoder
}
func setBitrate(_ layer: Int, _ bitrate: Int) {
@@ -976,7 +1073,9 @@ private final class EnvelopeCollector {
}
}
-private func syntheticPixelBuffer() throws -> CVPixelBuffer {
+private func syntheticPixelBuffer(
+ width imageWidth: Int = width, height imageHeight: Int = height
+) throws -> CVPixelBuffer {
let attributes = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
] as CFDictionary
@@ -984,8 +1083,8 @@ private func syntheticPixelBuffer() throws -> CVPixelBuffer {
try require(
CVPixelBufferCreate(
kCFAllocatorDefault,
- width,
- height,
+ imageWidth,
+ imageHeight,
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
attributes,
&value
@@ -1030,15 +1129,88 @@ private func containsRequiredH264Units(_ envelope: Data) -> Bool {
private func selfTest() throws {
let collector = EnvelopeCollector()
let writer = ProtocolWriter(output: { collector.append($0) })
- let encoder = try HardwareEncoder(writer: writer, done: StopSignal())
- encoder.encode(
- try syntheticPixelBuffer(),
- timestamp: CMTime(value: 1, timescale: frameRate)
- )
- encoder.flush()
- encoder.close()
- guard collector.snapshot().contains(where: containsRequiredH264Units) else {
- throw CaptureFailure(description: "hardware H.264 self-test produced no IDR")
+ let encoder = HardwareEncoder(writer: writer, done: StopSignal())
+ defer { encoder.close() }
+ let cases = [(1280, 720, 1280, 720), (720, 1280, 404, 720),
+ (1720, 720, 1280, 534), (640, 480, 640, 480), (1280, 720, 1280, 720)]
+ for (index, item) in cases.enumerated() {
+ let previous = collector.snapshot().count
+ encoder.encode(
+ VideoImage(try syntheticPixelBuffer(width: item.0, height: item.1)),
+ timestamp: CMTime(value: Int64(index + 1), timescale: frameRate)
+ )
+ encoder.flush()
+ guard let envelope = collector.snapshot().dropFirst(previous).first(where: containsRequiredH264Units),
+ Int(envelope[24]) * 256 + Int(envelope[25]) == item.2,
+ Int(envelope[26]) * 256 + Int(envelope[27]) == item.3 else {
+ throw CaptureFailure(description: "hardware H.264 resize self-test failed at \(index)")
+ }
+ }
+}
+
+private func geometrySelfTest() throws {
+ let profiles: [(UInt32, Bool)] = [
+ (0x42c034, true), (0x42e034, true), (0x42c00a, true),
+ (0x4d8034, true), (0x58c034, true),
+ (0x4d0034, false), (0x640034, false), (0x42c035, false), (0x42c134, false),
+ ]
+ for (profile, expected) in profiles {
+ guard supportedH264Profile(UInt8(profile >> 16), UInt8((profile >> 8) & 0xff),
+ UInt8(profile & 0xff)) == expected else {
+ throw CaptureFailure(description: "H.264 profile admission self-test failed")
+ }
+ }
+ let buffer = try syntheticPixelBuffer()
+ let ceiling = OutputProfile(width: 1280, height: 720, fps: frameRate, bitrate: bitrate)
+ let rect = CGRect(x: 50, y: 40, width: 200, height: 300)
+ let attachments: [SCStreamFrameInfo: Any] = [
+ .contentRect: rect.dictionaryRepresentation,
+ .scaleFactor: CGFloat(2), .contentScale: CGFloat(1),
+ ]
+ // Core Video returns a lower-left origin; the surface crop starts 80 px
+ // from the top, so its lower edge is 720 - 80 - 600 = 40 px from the bottom.
+ guard let image = VideoImage(buffer, attachments: attachments),
+ try image.fitted(to: ceiling).width == 400,
+ try image.fitted(to: ceiling).height == 600,
+ CVImageBufferGetCleanRect(buffer) == CGRect(x: 100, y: 40, width: 400, height: 600) else {
+ throw CaptureFailure(description: "capture content geometry self-test failed")
+ }
+ var upscaled = attachments
+ upscaled[.contentScale] = CGFloat(2)
+ guard let smaller = VideoImage(buffer, attachments: upscaled),
+ try smaller.fitted(to: ceiling).width == 200,
+ try smaller.fitted(to: ceiling).height == 300 else {
+ throw CaptureFailure(description: "small capture was enlarged")
+ }
+ for value in [CGFloat(0), -.infinity, .nan] {
+ var invalid = attachments
+ invalid[.contentScale] = value
+ guard VideoImage(buffer, attachments: invalid) == nil else {
+ throw CaptureFailure(description: "invalid capture scale was accepted")
+ }
+ }
+ var outside = attachments
+ outside[.contentRect] = CGRect(x: 700, y: 0, width: 200, height: 300).dictionaryRepresentation
+ guard VideoImage(buffer, attachments: outside) == nil else {
+ throw CaptureFailure(description: "out-of-bounds capture content was accepted")
+ }
+ // Generated Constrained Baseline: 3856x2176 storage, 3840x2160 visible.
+ // Exercise CoreMedia's SPS interpretation without allocating a decoder.
+ let sps = Data([0x67, 0x42, 0xc0, 0x33, 0xd9, 0x00, 0x3c, 0x40, 0x44,
+ 0x78, 0x98, 0x9c, 0x04, 0x40, 0x00, 0x00, 0x03, 0x00,
+ 0x40, 0x00, 0x00, 0x0c, 0x23, 0xc6, 0x0c, 0x92])
+ let pps = Data([0x68, 0xcb, 0x83, 0xcb, 0x20])
+ let format = try h264InputFormat(sps: sps, pps: pps)
+ guard CMVideoFormatDescriptionGetCleanAperture(format, originIsAtTopLeft: true) ==
+ CGRect(x: 0, y: 0, width: 3840, height: 2160) else {
+ throw CaptureFailure(description: "H.264 visible aperture self-test failed")
+ }
+ // Uncropped 5120x2880 exceeds the visible-picture budget. Raw SPS storage
+ // and reference counts are validated by Go before reaching this process;
+ // CoreMedia can fold SPS cropping into its reported format dimensions.
+ let oversized = Data([0x67, 0x42, 0xc0, 0x34, 0xda, 0x00, 0x50, 0x00, 0x5a, 0x64])
+ guard (try? h264InputFormat(sps: oversized, pps: pps)) == nil else {
+ throw CaptureFailure(description: "H.264 oversized visible picture was accepted")
}
}
@@ -1237,7 +1409,7 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate {
private let done: StopSignal
private let queue: DispatchQueue
private let profile: VideoProfile
- private var lastImage: CVImageBuffer?
+ private var lastImage: VideoImage?
private var lastTimestamp = CMTime.invalid
init(
@@ -1285,9 +1457,11 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate {
sampleBuffer,
createIfNecessary: false
) as? [[SCStreamFrameInfo: Any]],
- let rawStatus = attachments.first?[.status] as? Int,
+ let attachments = attachments.first,
+ let rawStatus = attachments[.status] as? Int,
SCFrameStatus(rawValue: rawStatus) == .complete,
- let image = CMSampleBufferGetImageBuffer(sampleBuffer) else {
+ let buffer = CMSampleBufferGetImageBuffer(sampleBuffer),
+ let image = VideoImage(buffer, attachments: attachments) else {
return
}
let capturedTimestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
@@ -1340,6 +1514,18 @@ private func captureFilter(
throw CaptureFailure(description: "capture target kind is unsupported")
}
+private func validVideoSize(_ width: Int, _ height: Int) -> Bool {
+ width >= 2 && width <= maxVideoDimension && width % 2 == 0 &&
+ height >= 2 && height <= maxVideoDimension && height % 2 == 0 &&
+ ((width + 15) / 16) * ((height + 15) / 16) * 256 <= maxVideoPixels
+}
+
+private func validVideoFormat(_ width: Int, _ height: Int, _ fps: Int32) -> Bool {
+ // Match the shared H.264 Level 5.2 receive ceiling, including numeric sizes.
+ validVideoSize(width, height) && fps >= 1 && fps <= maxVideoFramerate &&
+ ((width + 15) / 16) * ((height + 15) / 16) * Int(fps) <= 2_073_600
+}
+
private func videoProfile(_ arguments: [String]) throws -> VideoProfile {
guard arguments.count >= 28,
arguments[10] == "--width", let width = Int(arguments[11]),
@@ -1353,13 +1539,8 @@ private func videoProfile(_ arguments: [String]) throws -> VideoProfile {
arguments[22] == "--protocol-v7" else {
throw CaptureFailure(description: "invalid video profile arguments")
}
- let validResolution =
- (width == 854 && height == 480) ||
- (width == 1280 && height == 720) ||
- (width == 1920 && height == 1080) ||
- (width == 2560 && height == 1440)
- guard validResolution, frameRate >= 15, frameRate <= 60,
- bitrate >= 2_000_000, bitrate <= 12_000_000 else {
+ guard validVideoFormat(width, height, frameRate), width >= 4, height >= 4,
+ bitrate >= 100_000, bitrate <= maxVideoBitrate else {
throw CaptureFailure(description: "video profile is outside the product bounds")
}
return VideoProfile(
@@ -1384,10 +1565,10 @@ private func outputProfiles(_ arguments: [String], start: Int = 23,
let height = Int(arguments[index + 2]),
let fps = Int32(arguments[index + 3]),
let bitrate = Int(arguments[index + 4]),
- width >= 2, width <= (source?.width ?? 2560), width % 2 == 0,
- height >= 2, height <= (source?.height ?? 1440), height % 2 == 0,
- fps >= 1, fps <= (source?.frameRate ?? 60),
- bitrate >= 1000, bitrate <= (source?.bitrate ?? 12_000_000) else {
+ validVideoFormat(width, height, fps),
+ width <= (source?.width ?? maxVideoDimension), height <= (source?.height ?? maxVideoDimension),
+ fps <= (source?.frameRate ?? maxVideoFramerate),
+ bitrate >= 1000, bitrate <= (source?.bitrate ?? maxVideoBitrate) else {
throw CaptureFailure(description: "output profile is outside source bounds")
}
result.append(OutputProfile(width: width, height: height, fps: fps, bitrate: bitrate))
@@ -1438,8 +1619,7 @@ private func readInput(done: StopSignal, outputs: [OutputProfile] = [],
let timestamp = number(8..<16), duration = number(16..<24)
let width = number(24..<26), height = number(26..<28)
guard size <= maxPayloadBytes, header[6] <= 1, header[7] == 0,
- width >= 2, width <= 2560, width % 2 == 0,
- height >= 2, height <= 1440, height % 2 == 0,
+ validVideoSize(Int(width), Int(height)),
timestamp <= UInt64(Int64.max) / 100,
duration > 0, duration <= UInt64(Int64.max) / 100,
let payload = try readExactly(size) else {
@@ -1488,6 +1668,41 @@ private func readInput(done: StopSignal, outputs: [OutputProfile] = [],
}
}
+private func h264InputFormat(sps: Data, pps: Data) throws -> CMVideoFormatDescription {
+ var format: CMFormatDescription?
+ let status = sps.withUnsafeBytes { first in
+ pps.withUnsafeBytes { second in
+ let pointers = [first.bindMemory(to: UInt8.self).baseAddress!,
+ second.bindMemory(to: UInt8.self).baseAddress!]
+ let sizes = [sps.count, pps.count]
+ return CMVideoFormatDescriptionCreateFromH264ParameterSets(
+ allocator: kCFAllocatorDefault, parameterSetCount: 2,
+ parameterSetPointers: pointers, parameterSetSizes: sizes,
+ nalUnitHeaderLength: 4, formatDescriptionOut: &format)
+ }
+ }
+ try require(status, "decode-h264-format")
+ guard let format else { throw CaptureFailure(description: "missing input format") }
+ // Go owns raw SPS storage/reference admission. CoreMedia may report a
+ // crop-normalized format; check that description and its aperture here.
+ let dimensions = CMVideoFormatDescriptionGetDimensions(format)
+ let width = Int(dimensions.width), height = Int(dimensions.height)
+ guard width >= 2, width <= maxCodedVideoDimension, width % 2 == 0,
+ height >= 2, height <= maxCodedVideoDimension, height % 2 == 0,
+ width * height <= maxCodedVideoPixels else {
+ throw CaptureFailure(description: "decoded input format dimensions exceed their bound")
+ }
+ // The clean aperture owns visible size when the format retains padding.
+ let area = CMVideoFormatDescriptionGetCleanAperture(format, originIsAtTopLeft: true)
+ let bounds = CGRect(x: 0, y: 0, width: width, height: height)
+ guard area.minX.isFinite, area.minY.isFinite, area.width.isFinite, area.height.isFinite,
+ area == area.integral, bounds.contains(area),
+ validVideoSize(Int(area.width), Int(area.height)) else {
+ throw CaptureFailure(description: "decoded input aperture exceeds its picture bound")
+ }
+ return format
+}
+
private final class H264InputDecoder {
private let encoders: EncoderGroup
private let lock = NSLock()
@@ -1555,24 +1770,7 @@ private final class H264InputDecoder {
guard recovery, !nextSPS.isEmpty, !nextPPS.isEmpty else {
throw CaptureFailure(description: "new H.264 input needs SPS, PPS and IDR")
}
- var nextFormat: CMFormatDescription?
- let status = nextSPS.withUnsafeBytes { first in
- nextPPS.withUnsafeBytes { second in
- let pointers = [first.bindMemory(to: UInt8.self).baseAddress!, second.bindMemory(to: UInt8.self).baseAddress!]
- let sizes = [nextSPS.count, nextPPS.count]
- return CMVideoFormatDescriptionCreateFromH264ParameterSets(
- allocator: kCFAllocatorDefault, parameterSetCount: 2,
- parameterSetPointers: pointers, parameterSetSizes: sizes,
- nalUnitHeaderLength: 4, formatDescriptionOut: &nextFormat)
- }
- }
- try require(status, "decode-h264-format")
- guard let nextFormat else { throw CaptureFailure(description: "missing input format") }
- let dimensions = CMVideoFormatDescriptionGetDimensions(nextFormat)
- guard dimensions.width >= 2, dimensions.width <= 2560,
- dimensions.height >= 2, dimensions.height <= 1440 else {
- throw CaptureFailure(description: "decoded input exceeds its dimension bound")
- }
+ let nextFormat = try h264InputFormat(sps: nextSPS, pps: nextPPS)
if let session, !VTDecompressionSessionCanAcceptFormatDescription(session, formatDescription: nextFormat) {
VTDecompressionSessionInvalidate(session)
self.session = nil
@@ -1622,7 +1820,7 @@ private final class H264InputDecoder {
try require(decodeStatus, "decode-output")
guard let image else { throw CaptureFailure(description: "decoder did not produce an image") }
lastTimestamp = input.timestamp
- try encoders.encode(image, timestamp: input.timestamp)
+ try encoders.encode(VideoImage(image), timestamp: input.timestamp)
}
}
@@ -1688,6 +1886,8 @@ private func capture(_ arguments: [String]) async throws {
let configuration = SCStreamConfiguration()
configuration.width = profile.width
configuration.height = profile.height
+ configuration.scalesToFit = true
+ if #available(macOS 14.0, *) { configuration.preservesAspectRatio = true }
configuration.minimumFrameInterval = profile.frameInterval
configuration.queueDepth = 5
configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
@@ -1912,6 +2112,8 @@ private struct PiikCapture {
try probe()
} else if arguments.count == 2, arguments[1] == "--self-test" {
try selfTest()
+ } else if arguments.count == 2, arguments[1] == "--geometry-self-test" {
+ try geometrySelfTest()
} else if arguments.count == 2, arguments[1] == "--list" {
try await listSources()
} else if arguments.count == 2, arguments[1] == "--list-microphones" {
diff --git a/native/capture/linux/README.md b/native/capture/linux/README.md
index 6428e97c5..814348841 100644
--- a/native/capture/linux/README.md
+++ b/native/capture/linux/README.md
@@ -20,10 +20,11 @@ sh native/capture/linux/build.sh /outside/repository/build
```
Run `npm run check:native` on Linux to compile the sidecar, run the headless
-output-profile, segment-timestamp and retirement regressions, and validate its probe/source list.
+output-profile, source-geometry, segment-timestamp and retirement regressions,
+and validate its probe/source list.
The same check runs when the Linux CI job packages an App candidate. For the
native build and regression alone, add `--check` to the command above. The test
-uses GStreamer's core synthetic elements and needs no display, Portal session,
+uses synthetic input and GStreamer's core/base elements and needs no display, Portal session,
capture device or hardware encoder; physical capture remains a separate check.
Input ticks, output activation and encoded frames use GStreamer running time,
@@ -32,6 +33,11 @@ Keyframe events retain raw PTS for their timestamp field and normalized running
time for their running-time field. The Portal stream uses the pipeline clock so
unchanged content does not stop output keepalives.
+GStreamer fits each source within the output's even-dimension ceilings while
+preserving presentation aspect. It renegotiates those dimensions on source
+resize; the encoded sample's caps supply the actual frame size. Requested
+profile dimensions remain configuration facts, not measured output.
+
Each output bin contains its downstream flow failures before they reach the
shared tee. The main-context bus handler reports that output as unavailable and
closes its valve before stopping the branch. Failed outputs remain retired for
diff --git a/native/capture/linux/main.c b/native/capture/linux/main.c
index 855140635..c4e6628d0 100644
--- a/native/capture/linux/main.c
+++ b/native/capture/linux/main.c
@@ -19,6 +19,10 @@ enum {
kMaxOutputs = 6,
kMaxPayloadBytes = 4 * 1024 * 1024,
kAudioFrameBytes = 960 * 2 * 2,
+ kMaxVideoDimension = 8192,
+ kMaxVideoPixels = 36864 * 16 * 16,
+ kMaxVideoFramerate = 240,
+ kMaxVideoBitrate = 240000000,
};
typedef enum {
@@ -370,6 +374,18 @@ static gboolean parse_uint(const char *value, guint minimum, guint maximum,
return TRUE;
}
+static gboolean valid_video_size(guint width, guint height) {
+ return width >= 2 && width <= kMaxVideoDimension && width % 2 == 0 &&
+ height >= 2 && height <= kMaxVideoDimension && height % 2 == 0 &&
+ (guint64)((width + 15) / 16) * ((height + 15) / 16) * 256 <= kMaxVideoPixels;
+}
+
+static gboolean valid_video_format(guint width, guint height, guint fps) {
+ // Match the shared H.264 Level 5.2 receive ceiling, including numeric sizes.
+ return valid_video_size(width, height) && fps >= 1 && fps <= kMaxVideoFramerate &&
+ (guint64)((width + 15) / 16) * ((height + 15) / 16) * fps <= 2073600;
+}
+
static gboolean parse_outputs(int count, char **values, guint start, CaptureRun *run) {
if (count <= (int)start || (count - (int)start) % 5 != 0 ||
(count - (int)start) / 5 > kMaxOutputs) return FALSE;
@@ -385,11 +401,12 @@ static gboolean parse_outputs(int count, char **values, guint start, CaptureRun
output->activation_timestamp = GST_CLOCK_TIME_NONE;
output->profile.preference = run->profile.preference;
if (strcmp(values[offset], "--output") != 0 ||
- !parse_uint(values[offset + 1], 2, 2560, &output->profile.width) ||
- !parse_uint(values[offset + 2], 2, 1440, &output->profile.height) ||
- !parse_uint(values[offset + 3], 1, 60, &output->profile.frame_rate) ||
- !parse_uint(values[offset + 4], 1000, 12000000, &output->profile.bit_rate) ||
- output->profile.width % 2 != 0 || output->profile.height % 2 != 0) return FALSE;
+ !parse_uint(values[offset + 1], 2, kMaxVideoDimension, &output->profile.width) ||
+ !parse_uint(values[offset + 2], 2, kMaxVideoDimension, &output->profile.height) ||
+ !parse_uint(values[offset + 3], 1, kMaxVideoFramerate, &output->profile.frame_rate) ||
+ !parse_uint(values[offset + 4], 1000, kMaxVideoBitrate, &output->profile.bit_rate) ||
+ !valid_video_format(output->profile.width, output->profile.height,
+ output->profile.frame_rate)) return FALSE;
}
return TRUE;
}
@@ -439,16 +456,12 @@ static gboolean parse_profile(int count, char **values, CaptureRun *run,
!parse_uint(values[9], 0, 63, encoder_index) ||
!parse_uint(values[11], 1, 16384, &profile->width) ||
!parse_uint(values[13], 1, 16384, &profile->height) ||
- !parse_uint(values[15], 15, 60, &profile->frame_rate) ||
- !parse_uint(values[17], 2000000, 12000000, &profile->bit_rate)) {
+ !parse_uint(values[15], 1, kMaxVideoFramerate, &profile->frame_rate) ||
+ !parse_uint(values[17], 100000, kMaxVideoBitrate, &profile->bit_rate)) {
return FALSE;
}
- gboolean resolution =
- (profile->width == 854 && profile->height == 480) ||
- (profile->width == 1280 && profile->height == 720) ||
- (profile->width == 1920 && profile->height == 1080) ||
- (profile->width == 2560 && profile->height == 1440);
- if (!resolution) return FALSE;
+ if (!valid_video_format(profile->width, profile->height, profile->frame_rate) ||
+ profile->width < 4 || profile->height < 4) return FALSE;
if (!parse_preference(values[19], profile) || !parse_outputs(count, values, 23, run)) return FALSE;
run->original_output = MIN(1, run->output_count - 1);
const VideoProfile *original = &run->outputs[run->original_output].profile;
@@ -538,9 +551,13 @@ static gboolean configure_encoder(VideoOutput *output) {
return TRUE;
}
-static gboolean supported_level(guint8 level) {
- const guint8 levels[] = {0x1e, 0x1f, 0x20, 0x28,
- 0x29, 0x2a, 0x32, 0x33};
+static gboolean supported_h264_profile(guint8 profile, guint8 constraints, guint8 level) {
+ // RFC 6184 Table 5: equivalent Constrained Baseline tools, as in the Go reader.
+ if (!((profile == 0x42 && (constraints & 0x4f) == 0x40) ||
+ (profile == 0x4d && (constraints & 0x8f) == 0x80) ||
+ (profile == 0x58 && (constraints & 0xcf) == 0xc0))) return FALSE;
+ const guint8 levels[] = {0x0a, 0x0b, 0x0c, 0x0d, 0x14, 0x15, 0x16,
+ 0x1e, 0x1f, 0x20, 0x28, 0x29, 0x2a, 0x32, 0x33, 0x34};
for (guint index = 0; index < G_N_ELEMENTS(levels); ++index) {
if (levels[index] == level) return TRUE;
}
@@ -583,8 +600,8 @@ static NalSummary inspect_h264(const guint8 *data, gsize size) {
summary.sps |= type == 7;
summary.pps |= type == 8;
summary.idr |= type == 5;
- if (type == 7 && nal + 3 < size && data[nal + 1] == 0x42 &&
- data[nal + 2] == 0xc0 && supported_level(data[nal + 3])) {
+ if (type == 7 && nal + 3 < size &&
+ supported_h264_profile(data[nal + 1], data[nal + 2], data[nal + 3])) {
g_snprintf(summary.profile_level_id, sizeof(summary.profile_level_id),
"%02x%02x%02x", data[nal + 1], data[nal + 2],
data[nal + 3]);
@@ -596,18 +613,16 @@ static NalSummary inspect_h264(const guint8 *data, gsize size) {
return summary;
}
-static gboolean quit_loop(gpointer data) {
- CaptureRun *run = data;
- g_main_loop_quit(run->loop);
- return G_SOURCE_REMOVE;
-}
-
static void fail_run(CaptureRun *run, const char *message) {
g_mutex_lock(&run->lock);
gboolean first = run->failure == NULL;
if (first) run->failure = g_strdup(message);
g_mutex_unlock(&run->lock);
- if (first) g_main_context_invoke(NULL, quit_loop, run);
+ // MainContext.invoke can execute immediately before the loop starts, losing
+ // quit(). The pipeline bus queues the failure and owns its callback lifetime.
+ if (first) gst_element_post_message(run->pipeline,
+ gst_message_new_application(GST_OBJECT(run->pipeline),
+ gst_structure_new_empty("piik-capture-failed")));
}
static void fail_output(VideoOutput *output, const char *message) {
@@ -749,8 +764,7 @@ static gboolean valid_input_header(CaptureRun *run) {
run->input_size == 0 || run->input_size > kMaxPayloadBytes) return FALSE;
guint width = (guint)read_be(header + 24, 2), height = (guint)read_be(header + 26, 2);
guint64 timestamp = read_be(header + 8, 8), duration = read_be(header + 16, 8);
- return width >= 2 && width <= 2560 && height >= 2 && height <= 1440 &&
- width % 2 == 0 && height % 2 == 0 && duration > 0 &&
+ return valid_video_size(width, height) && duration > 0 &&
timestamp <= INT64_MAX / 100 && duration <= INT64_MAX / 100;
}
@@ -895,7 +909,10 @@ static GstPadProbeReturn output_input_frame(GstPad *pad, GstPadProbeInfo *info,
static gboolean bus_message(GstBus *bus, GstMessage *message, gpointer data) {
(void)bus;
CaptureRun *run = data;
- if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
+ if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_APPLICATION &&
+ gst_message_has_name(message, "piik-capture-failed")) {
+ g_main_loop_quit(run->loop);
+ } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
GError *error = NULL;
char *debug = NULL;
gst_message_parse_error(message, &error, &debug);
@@ -940,6 +957,17 @@ static GstFlowReturn video_sample(GstAppSink *sink, gpointer data) {
CaptureRun *run = output->run;
GstSample *sample = gst_app_sink_pull_sample(sink);
if (sample == NULL) return GST_FLOW_EOS;
+ GstCaps *caps = gst_sample_get_caps(sample);
+ const GstStructure *format = caps == NULL ? NULL : gst_caps_get_structure(caps, 0);
+ gint width = 0, height = 0;
+ if (format == NULL || !gst_structure_get_int(format, "width", &width) ||
+ !gst_structure_get_int(format, "height", &height) ||
+ !valid_video_size(width, height) ||
+ (guint)width > output->profile.width || (guint)height > output->profile.height) {
+ gst_sample_unref(sample);
+ fail_output(output, "GStreamer returned H.264 dimensions outside the output ceiling");
+ return GST_FLOW_ERROR;
+ }
GstBuffer *buffer = gst_sample_get_buffer(sample);
GstMapInfo mapped = GST_MAP_INFO_INIT;
if (buffer == NULL || !gst_buffer_map(buffer, &mapped, GST_MAP_READ) ||
@@ -981,7 +1009,7 @@ static GstFlowReturn video_sample(GstAppSink *sink, gpointer data) {
return GST_FLOW_OK;
}
gboolean written = write_frame(2, recovery ? 1 : 0, (guint8)output->layer,
- output->profile.width, output->profile.height,
+ width, height,
timestamp / 100,
10000000ULL / output->profile.frame_rate,
mapped.data, mapped.size);
@@ -1098,6 +1126,29 @@ static int run_pipeline(CaptureRun *run, const char *starting_status) {
return 0;
}
+static void append_video_scale(GString *pipeline, const VideoProfile *profile) {
+ // Let GStreamer preserve source aspect and renegotiate after a resize. The
+ // step keeps NV12 dimensions even; the profile is a ceiling, not a canvas.
+ g_string_append(pipeline, "videoconvert ! videoscale add-borders=false ! "
+ "video/x-raw,format=NV12,");
+ const struct { const char *name; guint limit; } dimensions[] = {
+ {"width", profile->width}, {"height", profile->height}};
+ for (guint index = 0; index < G_N_ELEMENTS(dimensions); ++index) {
+ // GStreamer represents a one-value range as a scalar.
+ g_string_append_printf(pipeline, dimensions[index].limit == 2
+ ? "%s=(int)%u," : "%s=(int)[2,%u,2],",
+ dimensions[index].name, dimensions[index].limit);
+ }
+ g_string_append(pipeline, "pixel-aspect-ratio=1/1 ! ");
+}
+
+static char *pipewire_target(const PiikPortalCapture *portal) {
+ // target-object accepts a name or object serial; path accepts a node ID.
+ return portal->target_object != NULL
+ ? g_strdup_printf("target-object=%s", portal->target_object)
+ : g_strdup_printf("path=%u", portal->node_id);
+}
+
static int capture_video(int count, char **values) {
CaptureRun run = {.last_input_timestamp = GST_CLOCK_TIME_NONE};
gboolean encoded = count > 1 && strcmp(values[1], "--encoded-video") == 0;
@@ -1130,26 +1181,31 @@ static int capture_video(int count, char **values) {
"max-buffers=1 max-bytes=1048576 max-time=0 "
"caps=video/x-h264,stream-format=byte-stream,alignment=au ! "
"h264parse ! decodebin ! video/x-raw ! tee name=frames ");
- } else g_string_append_printf(pipeline_text,
- "pipewiresrc name=source fd=%d target-object=%s do-timestamp=true ! "
- "videorate drop-only=true ! video/x-raw,framerate=%u/1 ! tee name=frames ",
- portal.pipewire_fd, portal.target_object, run.profile.frame_rate);
+ } else {
+ char *target = pipewire_target(&portal);
+ g_string_append_printf(pipeline_text,
+ "pipewiresrc name=source fd=%d %s do-timestamp=true ! "
+ "videorate drop-only=true ! video/x-raw,framerate=%u/1 ! tee name=frames ",
+ portal.pipewire_fd, target, run.profile.frame_rate);
+ g_free(target);
+ }
for (guint index = 0; index < run.output_count; ++index) {
const VideoProfile *profile = &run.outputs[index].profile;
g_string_append_printf(pipeline_text,
"frames. ! valve name=gate%u drop=%s ! ( "
"queue name=raw%u max-size-buffers=1 max-size-bytes=0 "
"max-size-time=0 leaky=downstream ! videorate drop-only=true ! "
- "video/x-raw,framerate=%u/1 ! videoconvert ! videoscale ! "
- "video/x-raw,format=NV12,width=%u,height=%u ! "
+ "video/x-raw,framerate=%u/1 ! ",
+ index, run.outputs[index].enabled ? "false" : "true",
+ index, profile->frame_rate);
+ append_video_scale(pipeline_text, profile);
+ g_string_append_printf(pipeline_text,
"%s name=encoder%u ! video/x-h264,profile=constrained-baseline ! "
"h264parse config-interval=-1 ! "
"video/x-h264,stream-format=byte-stream,alignment=au ! "
"appsink name=output%u emit-signals=true sync=false async=false "
"max-buffers=1 drop=false ) ",
- index, run.outputs[index].enabled ? "false" : "true",
- index, profile->frame_rate, profile->width, profile->height, encoder_info->factory,
- index, index);
+ encoder_info->factory, index, index);
}
GstElement *pipeline = gst_parse_launch(pipeline_text->str, &error);
g_string_free(pipeline_text, TRUE);
diff --git a/native/capture/linux/main.test.c b/native/capture/linux/main.test.c
index 591b59e84..3fe567dbe 100644
--- a/native/capture/linux/main.test.c
+++ b/native/capture/linux/main.test.c
@@ -1,7 +1,8 @@
-// build.sh --check uses synthetic GStreamer elements; no capture or codec is used.
+// build.sh --check uses synthetic video/GStreamer elements; no capture or codec is used.
#define main capture_main
#include "main.c"
#undef main
+#include
typedef struct { GstElement parent; guint bitrate; } FixtureEncoder;
typedef struct { GstElementClass parent; } FixtureEncoderClass;
@@ -30,7 +31,7 @@ static void fixture_encoder_class_init(FixtureEncoderClass *klass) {
object_class->set_property = fixture_set_property;
object_class->get_property = fixture_get_property;
g_object_class_install_property(object_class, 1,
- g_param_spec_uint("bitrate", "Bitrate", "Bitrate in bps", 1000, 12000000,
+ g_param_spec_uint("bitrate", "Bitrate", "Bitrate in bps", 1000, 50000000,
3000000, G_PARAM_READWRITE | GST_PARAM_MUTABLE_PLAYING));
}
@@ -62,8 +63,15 @@ static void check_encoder_admission_and_control(void) {
gst_object_unref(encoder);
}
+static gboolean capture_failure_timeout(gpointer data) {
+ (void)data;
+ g_error("capture failure was lost before the main loop started");
+ return G_SOURCE_REMOVE;
+}
+
static void check_bus_error_retains_primary_cause(void) {
- CaptureRun run = {.loop = g_main_loop_new(NULL, FALSE), .output_count = 1};
+ CaptureRun run = {.loop = g_main_loop_new(NULL, FALSE), .output_count = 1,
+ .pipeline = gst_pipeline_new(NULL)};
g_mutex_init(&run.lock);
run.outputs[0].run = &run;
run.outputs[0].encoder = gst_element_factory_make("identity", NULL);
@@ -85,9 +93,19 @@ static void check_bus_error_retains_primary_cause(void) {
unlink(frame_path);
g_free(frame_path);
g_assert_cmpstr(run.failure, ==, error->message);
+ // Startup callbacks can fail before the bus watch/main loop is running.
+ // The recorded failure must still retire the pipeline once dispatch starts.
+ GstBus *bus = gst_element_get_bus(run.pipeline);
+ guint watch = gst_bus_add_watch(bus, bus_message, &run);
+ guint timeout = g_timeout_add(500, capture_failure_timeout, NULL);
+ g_main_loop_run(run.loop);
+ g_source_remove(timeout);
+ g_source_remove(watch);
+ gst_object_unref(bus);
gst_message_unref(message);
g_error_free(error);
gst_object_unref(run.outputs[0].encoder);
+ gst_object_unref(run.pipeline);
g_free(run.failure);
g_main_loop_unref(run.loop);
g_mutex_clear(&run.lock);
@@ -135,6 +153,53 @@ static void check_slot_profiles(void) {
for (guint index = 0; index < run.output_count; ++index) {
g_assert_false(run.outputs[index].enabled);
}
+
+ char *numeric[] = {
+ "capture", "--capture-video", "picker", "1", "0", "0",
+ "--adapter-index", "0", "--mft-index", "0", "--width", "1920",
+ "--height", "1200", "--fps", "30", "--bitrate", "3000000",
+ "--preference", "balanced", "--codec", "h264", "--protocol-v7",
+ "--output", "1920", "1200", "30", "3000000"};
+ const struct { char *width; char *height; gboolean valid; } sizes[] = {
+ {"1920", "1200", TRUE}, {"720", "1280", TRUE}, {"4", "4", TRUE},
+ {"3840", "2160", TRUE}, {"2160", "3840", TRUE}, {"3000", "2764", TRUE},
+ {"0", "720", FALSE}, {"2", "720", FALSE}, {"1280", "2", FALSE},
+ {"1279", "720", FALSE}, {"1280", "719", FALSE},
+ {"5120", "1440", TRUE}, {"4096", "2304", TRUE},
+ {"8194", "100", FALSE}, {"5120", "2880", FALSE},
+ {"4096", "2306", FALSE}, {"6562", "1438", FALSE}};
+ for (guint index = 0; index < G_N_ELEMENTS(sizes); ++index) {
+ numeric[11] = numeric[24] = sizes[index].width;
+ numeric[13] = numeric[25] = sizes[index].height;
+ run = (CaptureRun){0};
+ g_assert_cmpint(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder), ==, sizes[index].valid);
+ }
+ numeric[11] = numeric[24] = "3840";
+ numeric[13] = numeric[25] = "2160";
+ numeric[15] = numeric[26] = "60";
+ run = (CaptureRun){0};
+ g_assert_true(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder));
+ numeric[15] = "30"; // Reject an excessive derived output independently.
+ g_assert_false(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder));
+ const struct { char *fps; char *bitrate; gboolean valid; } rates[] = {
+ {"1", "100000", TRUE}, {"5", "800000", TRUE}, {"60", "50000000", TRUE},
+ {"60", "240000000", TRUE}, {"65", "100000000", FALSE},
+ {"0", "100000", FALSE}, {"1", "99999", FALSE}, {"60", "240000001", FALSE}};
+ for (guint index = 0; index < G_N_ELEMENTS(rates); ++index) {
+ numeric[15] = numeric[26] = rates[index].fps;
+ numeric[17] = numeric[27] = rates[index].bitrate;
+ run = (CaptureRun){0};
+ g_assert_cmpint(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder), ==, rates[index].valid);
+ }
+ g_assert_false(valid_video_format(G_MAXUINT, G_MAXUINT, G_MAXUINT));
+ numeric[11] = numeric[24] = "1920";
+ numeric[13] = numeric[25] = "1080";
+ numeric[15] = numeric[26] = "240";
+ numeric[17] = numeric[27] = "240000000";
+ run = (CaptureRun){0};
+ g_assert_true(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder));
+ numeric[15] = numeric[26] = "241";
+ g_assert_false(parse_profile(G_N_ELEMENTS(numeric), numeric, &run, &encoder));
}
static GstPadProbeReturn record_key_request(GstPad *pad, GstPadProbeInfo *info,
@@ -174,7 +239,8 @@ static void check_encoder_timestamp_segment(GstClockTime offset) {
gst_pad_add_probe(source_pad, GST_PAD_PROBE_TYPE_BUFFER, begin_input_frame, &run, NULL);
gst_pad_add_probe(source_pad, GST_PAD_PROBE_TYPE_BUFFER, output_input_frame, &output, NULL);
gst_pad_add_probe(sink_pad, GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM, record_key_request, &request, NULL);
- GstCaps *caps = gst_caps_from_string("video/x-h264,stream-format=byte-stream,alignment=au");
+ GstCaps *caps = gst_caps_from_string(
+ "video/x-h264,stream-format=byte-stream,alignment=au,width=640,height=480");
GstBuffer *buffer = gst_buffer_new_allocate(NULL, sizeof(h264), NULL);
gst_buffer_fill(buffer, 0, h264, sizeof(h264));
GST_BUFFER_PTS(buffer) = offset + GST_SECOND;
@@ -209,6 +275,8 @@ static void check_encoder_timestamp_segment(GstClockTime offset) {
g_assert_cmpint(bytes[37], ==, 2);
g_assert_cmpint(bytes[38], ==, 1);
g_assert_cmpuint(read_be((guint8 *)bytes + 40, 8), ==, 3 * GST_SECOND / 100);
+ g_assert_cmpuint(read_be((guint8 *)bytes + 56, 2), ==, 640);
+ g_assert_cmpuint(read_be((guint8 *)bytes + 58, 2), ==, 480);
g_assert_cmpmem(bytes + 64, sizeof(h264), h264, sizeof(h264));
g_assert_cmpuint(output.activation_timestamp, ==, 3 * GST_SECOND);
g_assert_nonnull(request);
@@ -239,6 +307,66 @@ static GstState branch_state(VideoOutput *output) {
return state;
}
+static void check_source_geometry(guint max_width, guint max_height) {
+ const VideoProfile profile = {.width = max_width, .height = max_height};
+ GString *description = g_string_new("appsrc name=source format=time ! ");
+ append_video_scale(description, &profile);
+ g_string_append(description, "appsink name=output sync=false async=false");
+ GError *error = NULL;
+ GstElement *pipeline = gst_parse_launch(description->str, &error);
+ g_string_free(description, TRUE);
+ g_assert_no_error(error);
+ GstElement *source = gst_bin_get_by_name(GST_BIN(pipeline), "source");
+ GstElement *sink = gst_bin_get_by_name(GST_BIN(pipeline), "output");
+ g_assert_cmpint(gst_element_set_state(pipeline, GST_STATE_PLAYING), !=,
+ GST_STATE_CHANGE_FAILURE);
+ // Keep one pipeline while input caps change, as a resized captured window or
+ // decoded relay source does. Small sources must not be enlarged to the ceiling.
+ const guint sizes[][2] = {{1080, 1920}, {3440, 1440}, {640, 480},
+ {1920, 1080}, {641, 479}, {600, 600}};
+ for (guint index = 0; index < G_N_ELEMENTS(sizes); ++index) {
+ GstVideoInfo input;
+ g_assert_true(gst_video_info_set_format(&input, GST_VIDEO_FORMAT_NV12,
+ sizes[index][0], sizes[index][1]));
+ GstCaps *caps = gst_video_info_to_caps(&input);
+ gst_app_src_set_caps(GST_APP_SRC(source), caps);
+ gst_caps_unref(caps);
+ GstBuffer *buffer = gst_buffer_new_allocate(NULL, input.size, NULL);
+ gst_buffer_memset(buffer, 0, 128, input.size);
+ gst_buffer_memset(buffer, 0, 235, input.offset[1]);
+ GST_BUFFER_PTS(buffer) = index * GST_SECOND / 30;
+ GST_BUFFER_DURATION(buffer) = GST_SECOND / 30;
+ g_assert_cmpint(gst_app_src_push_buffer(GST_APP_SRC(source), buffer), ==, GST_FLOW_OK);
+ GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), GST_SECOND);
+ g_assert_nonnull(sample);
+ GstVideoInfo actual;
+ g_assert_true(gst_video_info_from_caps(&actual, gst_sample_get_caps(sample)));
+ g_assert_true(valid_video_size(actual.width, actual.height));
+ g_assert_cmpuint(actual.width, <=, max_width);
+ g_assert_cmpuint(actual.height, <=, max_height);
+ g_assert_cmpint(actual.par_n, ==, actual.par_d);
+ double scale = MIN(1.0, MIN((double)max_width / input.width,
+ (double)max_height / input.height));
+ g_assert_cmpfloat(ABS(actual.width - input.width * scale), <=, 2);
+ g_assert_cmpfloat(ABS(actual.height - input.height * scale), <=, 2);
+ GstVideoFrame frame;
+ g_assert_true(gst_video_frame_map(&frame, &actual, gst_sample_get_buffer(sample), GST_MAP_READ));
+ const guint8 *luma = GST_VIDEO_FRAME_PLANE_DATA(&frame, 0);
+ const gint stride = GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 0);
+ // No encoded letterbox: even the four corners still contain the source.
+ for (gint y = 0; y < actual.height; y += actual.height - 1) {
+ g_assert_cmpint(luma[y * stride], >=, 230);
+ g_assert_cmpint(luma[y * stride + actual.width - 1], >=, 230);
+ }
+ gst_video_frame_unmap(&frame);
+ gst_sample_unref(sample);
+ }
+ gst_element_set_state(pipeline, GST_STATE_NULL);
+ gst_object_unref(source);
+ gst_object_unref(sink);
+ gst_object_unref(pipeline);
+}
+
static void check_slot_retirement(void) {
CaptureRun run = {.output_count = 6};
g_mutex_init(&run.lock);
@@ -430,11 +558,44 @@ static void check_output_failure_isolation(gboolean post_error) {
g_mutex_clear(&run->lock);
}
+static void check_pipewire_target_identity(void) {
+ PiikPortalCapture portal = {.node_id = 42};
+ char *target = pipewire_target(&portal);
+ g_assert_cmpstr(target, ==, "path=42");
+ g_free(target);
+
+ portal.target_object = "9001";
+ target = pipewire_target(&portal);
+ g_assert_cmpstr(target, ==, "target-object=9001");
+ g_free(target);
+}
+
+static void check_h264_profile_admission(void) {
+ const struct { guint profile; gboolean supported; } cases[] = {
+ {0x42c034, TRUE}, {0x42e034, TRUE}, {0x42c00a, TRUE},
+ {0x4d8034, TRUE}, {0x58c034, TRUE},
+ {0x4d0034, FALSE}, {0x640034, FALSE}, {0x42c035, FALSE}, {0x42c134, FALSE}};
+ for (guint index = 0; index < G_N_ELEMENTS(cases); ++index) {
+ const guint profile = cases[index].profile;
+ const guint8 bytes[] = {0, 0, 0, 1, 0x67, profile >> 16, profile >> 8, profile,
+ 0, 0, 0, 1, 0x68, 0x80, 0, 0, 1, 0x65, 0x80};
+ const NalSummary nal = inspect_h264(bytes, sizeof(bytes));
+ g_assert_true(nal.sps && nal.pps && nal.idr);
+ char expected[7] = {0};
+ if (cases[index].supported) g_snprintf(expected, sizeof(expected), "%06x", profile);
+ g_assert_cmpstr(nal.profile_level_id, ==, expected);
+ }
+}
+
int main(int argc, char **argv) {
gst_init(&argc, &argv);
+ check_h264_profile_admission();
+ check_pipewire_target_identity();
check_encoder_admission_and_control();
check_bus_error_retains_primary_cause();
check_slot_profiles();
+ check_source_geometry(1920, 1080);
+ check_source_geometry(2, 2);
check_encoder_timestamp_segment(1000ULL * 60 * 60 * GST_SECOND);
check_encoder_timestamp_segment(0);
check_slot_retirement();
diff --git a/native/capture/linux/portal.c b/native/capture/linux/portal.c
index 586e8cd8f..4defa3bf4 100644
--- a/native/capture/linux/portal.c
+++ b/native/capture/linux/portal.c
@@ -125,9 +125,6 @@ gboolean piik_portal_capture_open(const char *restore_token,
piik_portal_capture_close(capture);
return FALSE;
}
- if (capture->target_object == NULL) {
- capture->target_object = g_strdup_printf("%u", capture->node_id);
- }
capture->restore_token = xdp_session_get_restore_token(capture->session);
return TRUE;
}
diff --git a/native/capture/windows/README.md b/native/capture/windows/README.md
index 51f7dd64e..8115ce8c0 100644
--- a/native/capture/windows/README.md
+++ b/native/capture/windows/README.md
@@ -33,7 +33,7 @@ unavailable instead of failing the whole source. Process loopback is probed by
activation rather than inferred from a Windows build number; display sources
normally use the standard render-device loopback available on Windows 10 and
later. The optional `--capture-audio exclude ` mode requires
-build 19041+ and a successful exclusion-mode probe. Windows 10 isolation still
+a successful exclusion-mode probe, without an additional OS-version gate. Windows 10 isolation still
needs physical verification; see the [audio evidence](../../../docs/research/native-client-media.md#windows-audio-exclusion).
It captures all output devices
except the selected process tree, and retires when that process exits. The
@@ -83,7 +83,10 @@ the old fixed one-frame VBV override is removed so later bitrate increases are
not trapped by the original buffer limit.
The build caches the pinned WebRTC SDK and matching official MSVC linker/runtime
-libraries under `build/encoder-pool`. The SDK supplies libvpx, replacing the
+libraries under `build/encoder-pool`. A newer installed MSVC keeps its own linker
+and runtime; older installations use that pinned minimum, following
+[Microsoft's binary compatibility rule](https://learn.microsoft.com/en-us/cpp/porting/binary-compat-2015-2017).
+The SDK supplies libvpx, replacing the
separate source build. Only statically linked code and required notices enter
the package, not the SDK/toolchain or another runtime service.
diff --git a/native/capture/windows/adaptive_encoder.cpp b/native/capture/windows/adaptive_encoder.cpp
index 8c5a4edc0..5b2942be5 100644
--- a/native/capture/windows/adaptive_encoder.cpp
+++ b/native/capture/windows/adaptive_encoder.cpp
@@ -508,7 +508,7 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface GetSupportedFormats() const override {
if (kind_ == OutputKind::vp8) return {webrtc::SdpVideoFormat("VP8")};
- return {webrtc::SdpVideoFormat("H264", {{"profile-level-id", ceiling_.profile_level_id()},
+ return {webrtc::SdpVideoFormat("H264", {{"profile-level-id", kH264ProfileLevelId},
{"packetization-mode", "1"}, {"level-asymmetry-allowed", "1"}})};
}
std::unique_ptr Create(const webrtc::Environment& env,
diff --git a/native/capture/windows/build.ps1 b/native/capture/windows/build.ps1
index 2a39a4098..a79838539 100644
--- a/native/capture/windows/build.ps1
+++ b/native/capture/windows/build.ps1
@@ -36,7 +36,19 @@ $includeFlags = @('', 'third_party\abseil-cpp', 'third_party\boringssl\src\inclu
}
$compileFlags = '/nologo /c /std:c++20 /EHsc /GR /O2 /W4 /WX /MT /D_ITERATOR_DEBUG_LEVEL=0 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A /external:W0 ' + ($includeFlags -join ' ')
$systemLibraries = 'ole32.lib mmdevapi.lib runtimeobject.lib user32.lib gdi32.lib dwmapi.lib shell32.lib mfplat.lib mf.lib mfuuid.lib d3d11.lib d2d1.lib dxgi.lib dxguid.lib evr.lib oleaut32.lib windowsapp.lib winmm.lib ws2_32.lib strmiids.lib crypt32.lib dmoguids.lib iphlpapi.lib msdmo.lib secur32.lib wmcodecdspuuid.lib'
-$linkCommand = '"{0}" /nologo /libpath:"{1}"' -f $webrtc.Linker,$webrtc.RuntimeLibraries
+# The linker and CRT must be at least as new as every input's toolchain.
+# Keep the pinned WebRTC minimum for older local installations, but do not
+# override a newer compiler's runtime on current Visual Studio/CI images.
+$toolsEnvironment = & cmd.exe /d /s /c ('call "{0}" -arch=x64 -host_arch=x64 >nul && set VCToolsInstallDir' -f $developerCommand)
+if ($LASTEXITCODE -ne 0) { throw 'Could not resolve the Visual Studio C++ toolchain.' }
+$installedTools = ($toolsEnvironment | Where-Object { $_.StartsWith('VCToolsInstallDir=') }) -replace '^VCToolsInstallDir=', ''
+if ([string]::IsNullOrWhiteSpace($installedTools)) { throw 'Visual Studio did not select a C++ toolchain.' }
+$installedVersion = [version](Split-Path -Leaf $installedTools.TrimEnd('\'))
+$linkCommand = if ($installedVersion -ge $webrtc.ToolchainVersion) {
+ 'link.exe /nologo'
+} else {
+ '"{0}" /nologo /libpath:"{1}"' -f $webrtc.Linker,$webrtc.RuntimeLibraries
+}
function Invoke-CaptureBuild([string]$command) {
& cmd.exe /d /s /c ('call "{0}" -arch=x64 -host_arch=x64 >nul && {1}' -f $developerCommand,$command) | Out-Host
diff --git a/native/capture/windows/capture_border.h b/native/capture/windows/capture_border.h
index 433b30ff5..29e79627b 100644
--- a/native/capture/windows/capture_border.h
+++ b/native/capture/windows/capture_border.h
@@ -47,7 +47,9 @@ class CaptureBorder final {
try {
if (access_.Status() == winrt::Windows::Foundation::AsyncStatus::Started) return;
const auto access = access_.GetResults();
- if (access == AccessStatus::Allowed) session.IsBorderRequired(false);
+ // Store the request even when consent is denied. Windows enforces current
+ // permission and can honor this preference if consent changes mid-share.
+ session.IsBorderRequired(false);
std::osyncstream(std::cerr) << "capture-border-access="
<< (access == AccessStatus::Allowed ? "allowed" : "not-allowed")
<< " status=" << static_cast(access) << '\n';
diff --git a/native/capture/windows/capture_control.test.cpp b/native/capture/windows/capture_control.test.cpp
index b94cb9946..9b825e9d8 100644
--- a/native/capture/windows/capture_control.test.cpp
+++ b/native/capture/windows/capture_control.test.cpp
@@ -54,12 +54,12 @@ void CheckOutputPipe() {
CloseHandle(output);
}
-void CheckWorkers() {
+void CheckMailboxWorkers() {
struct Input { uint32_t index; };
using Mailbox = piik::capture::OutputMailbox;
std::array mailboxes{Mailbox(90'000), Mailbox(300'000), Mailbox(1'200'000)};
std::array workers;
- std::array last{}, count{}, retired{};
+ std::array last{}, count{}, retired{}, bitrate{};
std::array recovery{};
std::mutex mutex;
std::condition_variable changed;
@@ -71,15 +71,10 @@ void CheckWorkers() {
for (size_t layer = 0; layer < workers.size(); ++layer) {
workers[layer] = std::thread([&, layer]() {
try {
- const uint32_t width = 160u << layer;
- const uint32_t height = 90u << layer;
- std::vector pixels(static_cast(width) * height * 3 / 2, 128);
- std::unique_ptr encoder;
for (;;) {
const auto work = mailboxes[layer].Take();
if (work.action == Mailbox::Action::stop) break;
if (work.action == Mailbox::Action::retire) {
- encoder.reset();
std::lock_guard lock(mutex);
++retired[layer];
changed.notify_one();
@@ -89,14 +84,13 @@ void CheckWorkers() {
entered.set_value();
released.wait();
}
- if (!encoder) encoder = std::make_unique(width, height, 30, work.bitrate);
- encoder->SetBitrate(work.bitrate);
- const auto output = encoder->Encode(pixels.data(), width,
- static_cast(work.input->index) * 10'000'000 / 30, work.recovery);
+ // Test mailbox ordering independently of codec speed. The separate
+ // encode/decode check below exercises real frames and keyframe output.
if (!mailboxes[layer].Accept(work.generation)) continue;
std::lock_guard lock(mutex);
last[layer] = work.input->index;
- recovery[layer] = output.key_frame;
+ bitrate[layer] = work.bitrate;
+ recovery[layer] = work.recovery;
++count[layer];
changed.notify_one();
}
@@ -143,7 +137,7 @@ void CheckWorkers() {
mailboxes[0].RequestKeyFrame();
release.set_value();
wait("pending-recovery", [&]() { return last[0] == 4; });
- assert(count[0] == 2 && recovery[0]); // New request survived the older keyframe; pending 2/3 were replaced.
+ assert(count[0] == 2 && recovery[0]); // New request survived older work; pending 2/3 were replaced.
mailboxes[1].SetActive(false);
mailboxes[2].SetActive(false);
submit(5);
@@ -154,6 +148,7 @@ void CheckWorkers() {
submit(6);
wait("output-reactivation", [&]() { return last[0] == 6 && last[1] == 6 && last[2] == 6; });
assert(recovery[0] && recovery[1] && recovery[2]);
+ assert(bitrate[0] == 30'000 && bitrate[1] == 300'000 && bitrate[2] == 1'200'000);
const auto stopped = std::chrono::steady_clock::now();
for (auto& mailbox : mailboxes) mailbox.Stop();
for (auto& worker : workers) worker.join();
@@ -263,7 +258,7 @@ int main() {
const auto decoded = decoder.Decode(output.bytes);
assert(decoded.width == 160 && decoded.height == 90 && decoded.nv12.size() == pixels.size());
}
- CheckWorkers();
+ CheckMailboxWorkers();
CheckDroppedRecoveryInput();
CheckIndependentActivation();
CheckOutputPipe();
diff --git a/native/capture/windows/capture_event.h b/native/capture/windows/capture_event.h
new file mode 100644
index 000000000..c89861685
--- /dev/null
+++ b/native/capture/windows/capture_event.h
@@ -0,0 +1,19 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+namespace piik::capture {
+
+// Revocation does not join an in-flight WinRT callback. Let the delegate retain
+// its signal until the final callback returns, without retaining the capture.
+inline auto CaptureEventHandler(std::shared_ptr event) {
+ return [event = std::move(event)](const auto&, const auto&) {
+ SetEvent(event->get());
+ };
+}
+
+} // namespace piik::capture
diff --git a/native/capture/windows/capture_geometry.h b/native/capture/windows/capture_geometry.h
index 3c1bff3db..5e199890c 100644
--- a/native/capture/windows/capture_geometry.h
+++ b/native/capture/windows/capture_geometry.h
@@ -1,9 +1,12 @@
#pragma once
+#include "video_bounds.h"
+
#include
#include
#include
+#include
namespace piik::capture {
@@ -40,4 +43,18 @@ inline RECT FitFrameRect(SIZE content, SIZE output) {
return {left, top, left + width, top + height};
}
+// A quality profile is an output ceiling, not a canvas to pad or upscale into.
+// Keep even dimensions for NV12; WebRTC can further adapt this bounded source.
+inline SIZE BoundedFrameSize(SIZE content, SIZE ceiling) {
+ if (content.cx <= 0 || content.cy <= 0 || ceiling.cx < 2 || ceiling.cy < 2)
+ throw std::invalid_argument("Invalid capture presentation dimensions");
+ const double scale = std::min({1.0,
+ static_cast(ceiling.cx) / content.cx,
+ static_cast(ceiling.cy) / content.cy});
+ return {
+ std::max(2, static_cast(content.cx * scale) & ~1L),
+ std::max(2, static_cast(content.cy * scale) & ~1L),
+ };
+}
+
} // namespace piik::capture
diff --git a/native/capture/windows/capture_geometry.test.cpp b/native/capture/windows/capture_geometry.test.cpp
index 766d11ab8..c9b75a7ec 100644
--- a/native/capture/windows/capture_geometry.test.cpp
+++ b/native/capture/windows/capture_geometry.test.cpp
@@ -49,5 +49,19 @@ int main() {
{240, 0, 1680, 1080});
const RECT wide = FitFrameRect({2560, 1080}, monitor);
assert(wide.left == 0 && wide.right == 1920 && wide.top > 0 && wide.bottom < 1080);
+ const auto expect_size = [&](SIZE content, SIZE expected) {
+ const SIZE actual = BoundedFrameSize(content, monitor);
+ assert(actual.cx == expected.cx && actual.cy == expected.cy);
+ };
+ expect_size({1080, 1920}, {606, 1080});
+ expect_size({3440, 1440}, {1920, 802});
+ expect_size({640, 480}, {640, 480});
+ expect_size({641, 481}, {640, 480});
+ expect_size({3840, 2160}, monitor);
+ expect_size(presentation(DISPLAYCONFIG_SCALING_STRETCHED), monitor);
+ try {
+ (void)BoundedFrameSize({}, monitor);
+ assert(false);
+ } catch (const std::invalid_argument&) {}
std::cout << "Windows capture presentation geometry passed.\n";
}
diff --git a/native/capture/windows/capture_input.h b/native/capture/windows/capture_input.h
index 8d56f29dc..1dee4fbe0 100644
--- a/native/capture/windows/capture_input.h
+++ b/native/capture/windows/capture_input.h
@@ -1,6 +1,7 @@
#pragma once
#include "capture_control.h"
+#include "video_bounds.h"
#include
#include
@@ -85,8 +86,7 @@ class InputEnvelopes final {
} else if ((frame_.kind != 2 && frame_.kind != 4) || header_[6] > 1 ||
frame_.duration == 0 || frame_.timestamp > static_cast(INT64_MAX) / 100 ||
frame_.duration > static_cast(INT64_MAX) / 100 ||
- frame_.width < 2 || frame_.height < 2 || frame_.width > 2560 || frame_.height > 1440 ||
- (frame_.width & 1) || (frame_.height & 1) || payload_size_ > 4 * 1024 * 1024) {
+ !ValidVideoSize(frame_.width, frame_.height) || payload_size_ > 4 * 1024 * 1024) {
throw std::runtime_error("Invalid native encoded input");
}
frame_.data.reserve(payload_size_);
diff --git a/native/capture/windows/capture_target.cpp b/native/capture/windows/capture_target.cpp
index f9a789cc5..158a23823 100644
--- a/native/capture/windows/capture_target.cpp
+++ b/native/capture/windows/capture_target.cpp
@@ -18,6 +18,7 @@
#include "capture_target.h"
#include "capture_border.h"
+#include "capture_event.h"
#include "capture_geometry.h"
#include "capture_sdr.h"
@@ -489,7 +490,8 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id,
Direct3D11CaptureFramePool pool{nullptr};
GraphicsCaptureSession session{nullptr};
CaptureBorder border;
- HANDLE frame_ready = CreateEventW(nullptr, FALSE, FALSE, nullptr);
+ auto frame_ready = std::make_shared(
+ CreateEventW(nullptr, FALSE, FALSE, nullptr));
winrt::event_token frame_token{};
bool subscribed = false;
HRESULT result = S_OK;
@@ -498,7 +500,7 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id,
if (SUCCEEDED(result)) result = CreateWgcItem(kind, source_id, &item);
if (SUCCEEDED(result)) {
auto size = item.Size();
- if (size.Width <= 0 || size.Height <= 0 || frame_ready == nullptr) {
+ if (size.Width <= 0 || size.Height <= 0 || !*frame_ready) {
result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA);
} else {
windows::CaptureDisplayColor display_color(kind, source_id);
@@ -508,15 +510,11 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id,
capture_device, static_cast(color.Format()), 1,
size);
session = pool.CreateCaptureSession(item);
- frame_token = pool.FrameArrived(
- [frame_ready](const Direct3D11CaptureFramePool&,
- const winrt::Windows::Foundation::IInspectable&) {
- if (frame_ready != nullptr) SetEvent(frame_ready);
- });
+ frame_token = pool.FrameArrived(CaptureEventHandler(frame_ready));
subscribed = true;
border.Start(session);
session.StartCapture();
- const DWORD wait = WaitForSingleObject(frame_ready, 1'500);
+ const DWORD wait = WaitForSingleObject(frame_ready->get(), 1'500);
border.Apply(session);
if (wait != WAIT_OBJECT_0) {
result = wait == WAIT_TIMEOUT
@@ -603,9 +601,6 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id,
capture_device = nullptr;
context.Reset();
device.Reset();
- if (frame_ready != nullptr) {
- CloseHandle(frame_ready);
- }
if (uninitialize) {
CoUninitialize();
}
diff --git a/native/capture/windows/capture_target.test.cpp b/native/capture/windows/capture_target.test.cpp
index b3d9895cc..9f293fcc2 100644
--- a/native/capture/windows/capture_target.test.cpp
+++ b/native/capture/windows/capture_target.test.cpp
@@ -1,4 +1,8 @@
#include "capture_target.h"
+#include "capture_event.h"
+
+#include
+#include
#include
#include
@@ -7,6 +11,32 @@
namespace {
+void CheckInFlightCaptureEvent() {
+ using namespace winrt::Windows::Graphics::Capture;
+ using namespace winrt::Windows::Foundation;
+ // A dispatched delegate can outlive both revocation and the capture owner.
+ // Exercise the handler shared by streaming capture and source thumbnails.
+ for (const bool manual_reset : {false, true}) {
+ auto signal = std::make_shared(
+ CreateEventW(nullptr, manual_reset, FALSE, nullptr));
+ assert(*signal);
+ const HANDLE handle = signal->get();
+ std::weak_ptr lifetime = signal;
+ TypedEventHandler in_flight{
+ piik::capture::CaptureEventHandler(signal)};
+ signal.reset();
+ assert(!lifetime.expired());
+
+ winrt::handle replacement{CreateEventW(nullptr, FALSE, FALSE, nullptr)};
+ assert(replacement);
+ in_flight(nullptr, nullptr);
+ assert(WaitForSingleObject(handle, 0) == WAIT_OBJECT_0);
+ assert(WaitForSingleObject(replacement.get(), 0) == WAIT_TIMEOUT);
+ in_flight = nullptr;
+ assert(lifetime.expired());
+ }
+}
+
void CheckOwnedApplicationWindow() {
HWND owner = CreateWindowExW(0, L"STATIC", L"Piik enumeration owner",
WS_OVERLAPPEDWINDOW, 0, 0, 320, 180, nullptr, nullptr, nullptr, nullptr);
@@ -57,6 +87,7 @@ int wmain(int count, wchar_t** arguments) {
};
return result == 0 && listed(arguments[2]) && !listed(arguments[3]) ? 0 : 1;
}
+ CheckInFlightCaptureEvent();
CheckOwnedApplicationWindow();
// Enumeration and preview are separate operations. A once-listed window may
// become hidden before WGC creates its item. Unsupported capture may return
diff --git a/native/capture/windows/get-webrtc.ps1 b/native/capture/windows/get-webrtc.ps1
index b8958a4c5..b3de932a1 100644
--- a/native/capture/windows/get-webrtc.ps1
+++ b/native/capture/windows/get-webrtc.ps1
@@ -56,6 +56,7 @@ if ($sdkNoticeHash -ne $notice.sha256 -or
}
$msvcPath = Join-Path $toolchainPath $pins.msvc.directory
return @{
+ ToolchainVersion = [version](Split-Path -Leaf $msvcPath)
Include = Join-Path $sdkPath 'include'
Library = Join-Path $sdkPath 'lib\webrtc.lib'
RuntimeLibraries = Join-Path $msvcPath 'lib\x64'
diff --git a/native/capture/windows/h264_decoder.h b/native/capture/windows/h264_decoder.h
index f7e0bd55f..1a5f29c14 100644
--- a/native/capture/windows/h264_decoder.h
+++ b/native/capture/windows/h264_decoder.h
@@ -39,16 +39,14 @@ class H264Decoder final {
}
Microsoft::WRL::ComPtr codec;
Check(decoder_.As(&codec), "codec-api");
- for (const auto& setting : {
- std::pair{CODECAPI_AVLowLatencyMode, TRUE},
- std::pair{CODECAPI_AVDecVideoMaxCodedWidth, 2560},
- std::pair{CODECAPI_AVDecVideoMaxCodedHeight, 1440}}) {
- VARIANT value;
- VariantInit(&value);
- value.vt = VT_UI4;
- value.ulVal = setting.second;
- Check(codec->SetValue(&setting.first, &value), "codec-setting");
- }
+ VARIANT value;
+ VariantInit(&value);
+ value.vt = VT_UI4;
+ value.ulVal = TRUE;
+ Check(codec->SetValue(&CODECAPI_AVLowLatencyMode, &value), "codec-setting");
+ // Let the MFT own its supported dimensions. Setting both global axis
+ // ceilings preallocates a square and can fail even for a small input.
+ // Go validates SPS storage before this process; SelectOutput bounds its buffers.
}
~H264Decoder() {
@@ -63,6 +61,8 @@ class H264Decoder final {
return windows::DecodedVideoColor(type.Get());
}
+ RECT DisplayArea() const { return display_area_; }
+
void Decode(const InputEnvelope& input, const Consumer& consume) {
if (input.kind != 2 || input.data.empty() || input.data.size() > 4 * 1024 * 1024)
throw std::runtime_error("Invalid H264 decoder input");
@@ -117,9 +117,31 @@ class H264Decoder final {
if (subtype != MFVideoFormat_NV12) continue;
UINT32 width = 0, height = 0;
Check(MFGetAttributeSize(type.Get(), MF_MT_FRAME_SIZE, &width, &height), "output-size");
- if (width < 2 || height < 2 || width > 2560 || height > 1440 || (width & 1) || (height & 1)) {
- throw std::runtime_error("H264 decoder output dimensions exceed the source bound");
+ if (!ValidCodedVideoSize(width, height)) {
+ throw std::runtime_error("H264 decoder output storage exceeds its bound");
+ }
+ // MF_MT_FRAME_SIZE includes codec padding. The display aperture owns
+ // visible pixels, independently of the bounded decoder allocation.
+ MFVideoArea area{};
+ UINT32 area_bytes = 0;
+ HRESULT aperture = type->GetBlob(MF_MT_MINIMUM_DISPLAY_APERTURE,
+ reinterpret_cast(&area), sizeof(area), &area_bytes);
+ if (aperture == MF_E_ATTRIBUTENOTFOUND)
+ aperture = type->GetBlob(MF_MT_GEOMETRIC_APERTURE,
+ reinterpret_cast(&area), sizeof(area), &area_bytes);
+ if (aperture == MF_E_ATTRIBUTENOTFOUND) {
+ area = {{0, 0}, {0, 0}, {static_cast(width), static_cast(height)}};
+ } else {
+ Check(aperture, "display-aperture");
+ if (area_bytes != sizeof(area)) throw std::runtime_error("H264 display aperture is incomplete");
}
+ if (area.OffsetX.value < 0 || area.OffsetY.value < 0 ||
+ area.OffsetX.fract || area.OffsetY.fract ||
+ (area.OffsetX.value & 1) || (area.OffsetY.value & 1) ||
+ !ValidVideoSize(area.Area.cx, area.Area.cy) ||
+ area.OffsetX.value + area.Area.cx > static_cast(width) ||
+ area.OffsetY.value + area.Area.cy > static_cast(height))
+ throw std::runtime_error("H264 display aperture exceeds its picture bound");
Check(decoder_->SetOutputType(0, type.Get(), 0), "set-output");
UINT32 stride = 0;
if (FAILED(type->GetUINT32(MF_MT_DEFAULT_STRIDE, &stride))) {
@@ -131,6 +153,8 @@ class H264Decoder final {
width_ = width;
height_ = height;
stride_ = static_cast(stride);
+ display_area_ = {area.OffsetX.value, area.OffsetY.value,
+ area.OffsetX.value + area.Area.cx, area.OffsetY.value + area.Area.cy};
return;
}
throw std::runtime_error("H264 decoder has no NV12 output");
@@ -142,11 +166,11 @@ class H264Decoder final {
Check(decoder_->GetOutputStreamInfo(0, &info), "output-info");
Microsoft::WRL::ComPtr owned;
if (!(info.dwFlags & (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES))) {
- const DWORD expected = static_cast(stride_) * height_ * 3 / 2;
- const DWORD size = std::max(info.cbSize, expected);
- if (size > 2560 * 1440 * 2) throw std::runtime_error("H264 decoder output allocation exceeds its bound");
+ const UINT64 expected = static_cast(stride_) * height_ * 3 / 2;
+ const UINT64 size = std::max(info.cbSize, expected);
+ if (size > kMaxCodedVideoPixels * 2) throw std::runtime_error("H264 decoder output allocation exceeds its bound");
Microsoft::WRL::ComPtr buffer;
- Check(MFCreateAlignedMemoryBuffer(size, info.cbAlignment ? info.cbAlignment - 1 : 0, &buffer), "output-buffer");
+ Check(MFCreateAlignedMemoryBuffer(static_cast(size), info.cbAlignment ? info.cbAlignment - 1 : 0, &buffer), "output-buffer");
Check(MFCreateSample(&owned), "output-sample");
Check(owned->AddBuffer(buffer.Get()), "output-sample-buffer");
}
@@ -174,6 +198,7 @@ class H264Decoder final {
UINT32 width_ = 0;
UINT32 height_ = 0;
LONG stride_ = 0;
+ RECT display_area_{};
bool configured_ = false;
};
diff --git a/native/capture/windows/h264_encoder.cpp b/native/capture/windows/h264_encoder.cpp
index f1cab0512..8b00e96e2 100644
--- a/native/capture/windows/h264_encoder.cpp
+++ b/native/capture/windows/h264_encoder.cpp
@@ -17,12 +17,19 @@ namespace {
constexpr DWORD kMaxEncodedSampleBytes = 4 * 1024 * 1024;
}
-bool VideoProfile::accepts_h264_profile_level_id(const std::string& value) const {
+bool SupportsH264ProfileLevelId(const std::string& value) {
if (value.size() != 6) return false;
UINT32 parsed = 0;
const auto end = value.data() + value.size();
const auto result = std::from_chars(value.data(), end, parsed, 16);
- if (result.ec != std::errc{} || result.ptr != end || (parsed & 0xff) != h264_level()) return false;
+ if (result.ec != std::errc{} || result.ptr != end) return false;
+ switch (parsed & 0xff) {
+ case 10: case 11: case 12: case 13: case 20: case 21: case 22:
+ case 30: case 31: case 32: case 40: case 41: case 42: case 50: case 51: case 52:
+ break;
+ default:
+ return false;
+ }
const auto idc = parsed >> 16;
const auto constraints = (parsed >> 8) & 0xff;
// RFC 6184 Table 5: these are the same Constrained Baseline tools, not
@@ -359,8 +366,8 @@ ComPtr CreateOutputType(
"output-type-interlace");
Check(type->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Base),
"output-type-profile");
- Check(type->SetUINT32(MF_MT_MPEG2_LEVEL, profile.h264_level()),
- "output-type-level");
+ // MF recommends automatic level selection. Forcing a valid level can still
+ // make hardware reject the type; validate the actual SPS instead.
return type;
}
@@ -412,8 +419,6 @@ void ValidateMediaTypes(
"output-type-bitrate");
RequireTypeU32(output.Get(), MF_MT_MPEG2_PROFILE,
eAVEncH264VProfile_Base, "output-type-profile");
- RequireTypeU32(output.Get(), MF_MT_MPEG2_LEVEL, profile.h264_level(),
- "output-type-level");
UINT32 width = 0;
UINT32 height = 0;
@@ -703,8 +708,8 @@ EncodedAccessUnit LiveEncoder::Encode(
Fail("bitstream-annexb", "live output is not Annex-B H264");
}
if (nal.profile_level_id) {
- if (!profile_.accepts_h264_profile_level_id(*nal.profile_level_id)) {
- Fail("bitstream-profile", "hardware MFT changed the requested H.264 profile level");
+ if (!SupportsH264ProfileLevelId(*nal.profile_level_id)) {
+ Fail("bitstream-profile", "hardware MFT emitted unsupported H.264 coding tools or level");
}
if (profile_level_id_ && *profile_level_id_ != *nal.profile_level_id) {
Fail("bitstream-profile-change", "hardware MFT changed SPS profile");
diff --git a/native/capture/windows/h264_encoder.h b/native/capture/windows/h264_encoder.h
index dd7ed10f8..f1ce69358 100644
--- a/native/capture/windows/h264_encoder.h
+++ b/native/capture/windows/h264_encoder.h
@@ -56,27 +56,11 @@ struct VideoProfile final {
if (preference == DegradationPreference::framerate) return 0;
return 50;
}
- UINT32 h264_level() const {
- const UINT64 macroblocks =
- ((static_cast(width) + 15) / 16) *
- ((static_cast(height) + 15) / 16);
- const UINT64 macroblocks_per_second = macroblocks * frame_rate;
- if (macroblocks <= 3'600 && macroblocks_per_second <= 108'000) return 31;
- if (macroblocks <= 8'192 && macroblocks_per_second <= 245'760) return 40;
- if (macroblocks <= 8'704 && macroblocks_per_second <= 522'240) return 42;
- if (macroblocks <= 22'080 && macroblocks_per_second <= 589'824) return 50;
- return 51;
- }
- std::string profile_level_id() const {
- std::ostringstream output;
- output << "42c0" << std::hex << std::setfill('0') << std::setw(2)
- << h264_level();
- return output.str();
- }
- bool accepts_h264_profile_level_id(const std::string& value) const;
};
constexpr VideoProfile kDefaultVideoProfile{};
+inline constexpr char kH264ProfileLevelId[] = "42c034";
+bool SupportsH264ProfileLevelId(const std::string& value);
struct Runtime final {
Runtime() {
diff --git a/native/capture/windows/main.cpp b/native/capture/windows/main.cpp
index 839e6b3d5..2a49b017e 100644
--- a/native/capture/windows/main.cpp
+++ b/native/capture/windows/main.cpp
@@ -31,6 +31,7 @@
#include "capture_target.h"
#include "capture_border.h"
+#include "capture_event.h"
#include "capture_geometry.h"
#include "capture_color.h"
#include "capture_sdr.h"
@@ -606,9 +607,9 @@ RunEvidence RunEncoder(const Adapter& adapter, const DeviceContext& device,
Fail("codec-runtime-bitrate-effect",
"live bitrate update did not lower and restore encoded output");
}
- if (!kDefaultVideoProfile.accepts_h264_profile_level_id(*observed_profile)) {
- Fail("bitstream-pinned-fmtp",
- "SPS profile-level-id differs from the native media contract");
+ if (!SupportsH264ProfileLevelId(*observed_profile)) {
+ Fail("bitstream-profile",
+ "SPS uses unsupported H.264 coding tools or level");
}
return evidence;
}
@@ -965,13 +966,9 @@ DegradationPreference ParseDegradationPreference(const wchar_t* value) {
}
void ValidateVideoProfile(const VideoProfile& profile) {
- const bool valid_resolution =
- (profile.width == 854 && profile.height == 480) ||
- (profile.width == 1280 && profile.height == 720) ||
- (profile.width == 1920 && profile.height == 1080) ||
- (profile.width == 2560 && profile.height == 1440);
- if (!valid_resolution || profile.frame_rate < 15 || profile.frame_rate > 60 ||
- profile.bit_rate < 2'000'000 || profile.bit_rate > 12'000'000) {
+ if (!piik::capture::ValidVideoFormat(profile.width, profile.height, profile.frame_rate) ||
+ profile.width < 4 || profile.height < 4 ||
+ profile.bit_rate < 100'000 || profile.bit_rate > piik::capture::kMaxVideoBitrate) {
Fail("argument-profile", "video profile is outside the product bounds");
}
}
@@ -984,9 +981,8 @@ void ParseOutputProfiles(ProductArguments& arguments, int first, int count, wcha
output.height = ParseIndex(values[index + 2], "argument-output-height");
output.frame_rate = ParseIndex(values[index + 3], "argument-output-fps");
output.bit_rate = ParseIndex(values[index + 4], "argument-output-bitrate");
- if (output.width < 2 || output.height < 2 || (output.width & 1) || (output.height & 1) ||
- output.width > 2560 || output.height > 1440 || output.frame_rate == 0 ||
- output.frame_rate > 60 || output.bit_rate < 1'000 || output.bit_rate > 12'000'000) {
+ if (!piik::capture::ValidVideoFormat(output.width, output.height, output.frame_rate) ||
+ output.bit_rate < 1'000 || output.bit_rate > piik::capture::kMaxVideoBitrate) {
Fail("argument-output", "output profile exceeds codec bounds");
}
if (arguments.mode == ProductArguments::Mode::video &&
@@ -1162,33 +1158,28 @@ UINT32 WindowsBuild() {
}
RTL_OSVERSIONINFOW version = {};
version.dwOSVersionInfoSize = sizeof(version);
- if (get_version(&version) < 0 || version.dwMajorVersion != 10) {
- Fail("windows-version", "unsupported Windows version");
+ if (get_version(&version) < 0) {
+ Fail("windows-version", "could not read Windows version");
}
return version.dwBuildNumber;
}
void WriteCapabilityProbe() {
- constexpr UINT32 kCreateForWindowMinimumBuild = 18'362;
- constexpr UINT32 kProcessLoopbackMinimumBuild = 19'041;
const UINT32 build = WindowsBuild();
bool window_capture = false;
- if (build >= kCreateForWindowMinimumBuild) {
- try {
- window_capture =
- winrt::Windows::Graphics::Capture::GraphicsCaptureSession::IsSupported();
- } catch (const winrt::hresult_error&) {
- window_capture = false;
- }
- }
- const bool process_audio =
- build >= kProcessLoopbackMinimumBuild &&
- piik::capture::ProcessAudioAvailable();
+ try {
+ using namespace winrt::Windows::Graphics::Capture;
+ // Probe the entry points used by capture and previews, not their OS versions.
+ window_capture = GraphicsCaptureSession::IsSupported() &&
+ winrt::Windows::Foundation::Metadata::ApiInformation::IsMethodPresent(
+ L"Windows.Graphics.Capture.Direct3D11CaptureFramePool", L"CreateFreeThreaded") &&
+ !!winrt::get_activation_factory();
+ } catch (const winrt::hresult_error&) {
+ window_capture = false;
+ }
+ const bool process_audio = piik::capture::ProcessAudioAvailable();
const bool system_audio = piik::capture::SystemAudioAvailable();
- // Process loopback was backported to Windows 10 2004. Probe each mode;
- // the documented 20348 minimum must not hide a working exclusion interface.
- const bool process_audio_exclusion = build >= kProcessLoopbackMinimumBuild &&
- piik::capture::ProcessAudioAvailable(true);
+ const bool process_audio_exclusion = piik::capture::ProcessAudioAvailable(true);
std::vector adapters = EnumerateAdapters();
std::ostringstream output;
@@ -1279,9 +1270,13 @@ bool SustainsEncodedCadence(const AdaptiveEncoder::Factory& create, ID3D11Device
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;
+ if (frame >= warmup_frames) {
+ if (output && !output->access_unit.bytes.empty()) ++delivered;
+ // Even perfect delivery of the remaining frames cannot recover this deficit.
+ if (frame - warmup_frames + 1 - delivered > lag) return false;
+ }
}
- return delivered + lag >= sample_frames;
+ return delivered > 0 && delivered + lag >= sample_frames;
}
struct VideoEncoderSelection final {
@@ -1470,6 +1465,8 @@ class OutputWorker final {
}
std::optional output;
try {
+ const auto size = piik::capture::BoundedFrameSize(input->presentation,
+ {static_cast(profile_.width), static_cast(profile_.height)});
if (!encoder) {
encoder = std::make_unique(
kind_, profile_, device_.Get(), create_, std::move(initial), layer_);
@@ -1485,7 +1482,8 @@ class OutputWorker final {
}
return converter->Convert(input->texture.Get(), input->width,
input->height, input->presentation, input->color_space);
- }, profile_.width, profile_.height, input->timestamp, work.recovery, work.bitrate);
+ }, static_cast(size.cx), static_cast(size.cy),
+ input->timestamp, work.recovery, work.bitrate);
} catch (...) {
// Device loss is current shared evidence; an old codec failure only
// retires its activation. No retry occurs without a new activation.
@@ -1663,27 +1661,38 @@ ComPtr UploadDecodedNV12(const DeviceContext& device,
}
ComPtr OwnDecodedTexture(const DeviceContext& device, IMFSample* sample,
- UINT32 width, UINT32 height, LONG stride) {
- ComPtr buffer;
- Check(sample->GetBufferByIndex(0, &buffer), "decoded-buffer");
- ComPtr dxgi;
- if (SUCCEEDED(buffer.As(&dxgi))) {
- ComPtr original;
- UINT subresource = 0;
- Check(dxgi->GetResource(IID_PPV_ARGS(&original)), "decoded-dxgi-resource");
- Check(dxgi->GetSubresourceIndex(&subresource), "decoded-dxgi-subresource");
+ UINT32 width, UINT32 height, LONG stride,
+ const RECT& display_area) {
+ // Copy the visible aperture while taking ownership of the decoder's storage.
+ // Downstream converters receive ordinary unpadded NV12, on either decoder path.
+ auto copy_visible = [&](ID3D11Texture2D* original, UINT subresource) {
D3D11_TEXTURE2D_DESC description{};
original->GetDesc(&description);
if (description.Format != DXGI_FORMAT_NV12 || description.Width < width || description.Height < height ||
- description.Width > 2560 || description.Height > 1440) Fail("decoded-texture-size", "decoder texture exceeds source bounds");
+ !piik::capture::ValidCodedVideoSize(description.Width, description.Height))
+ Fail("decoded-texture-size", "decoder texture exceeds source bounds");
+ description.Width = display_area.right - display_area.left;
+ description.Height = display_area.bottom - display_area.top;
description.ArraySize = description.MipLevels = 1;
description.Usage = D3D11_USAGE_DEFAULT;
description.CPUAccessFlags = description.MiscFlags = 0;
description.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
ComPtr owned;
Check(device.device->CreateTexture2D(&description, nullptr, &owned), "decoded-owned-texture");
- device.context->CopySubresourceRegion(owned.Get(), 0, 0, 0, 0, original.Get(), subresource, nullptr);
+ const D3D11_BOX source{static_cast(display_area.left), static_cast(display_area.top), 0,
+ static_cast(display_area.right), static_cast(display_area.bottom), 1};
+ device.context->CopySubresourceRegion(owned.Get(), 0, 0, 0, 0, original, subresource, &source);
return owned;
+ };
+ ComPtr buffer;
+ Check(sample->GetBufferByIndex(0, &buffer), "decoded-buffer");
+ ComPtr dxgi;
+ if (SUCCEEDED(buffer.As(&dxgi))) {
+ ComPtr original;
+ UINT subresource = 0;
+ Check(dxgi->GetResource(IID_PPV_ARGS(&original)), "decoded-dxgi-resource");
+ Check(dxgi->GetSubresourceIndex(&subresource), "decoded-dxgi-subresource");
+ return copy_visible(original.Get(), subresource);
}
const DWORD size = width * height * 3 / 2;
std::vector pixels(size);
@@ -1707,7 +1716,11 @@ ComPtr OwnDecodedTexture(const DeviceContext& device, IMFSample
Check(buffer->Unlock(), "decoded-memory-unlock");
if (!valid) Fail("decoded-memory-size", "NV12 output buffer is incomplete");
}
- return UploadDecodedNV12(device, pixels.data(), width, height);
+ auto uploaded = UploadDecodedNV12(device, pixels.data(), width, height);
+ if (display_area.left == 0 && display_area.top == 0 &&
+ display_area.right == static_cast(width) && display_area.bottom == static_cast(height))
+ return uploaded;
+ return copy_visible(uploaded.Get(), 0);
}
void RunEncodedVideo(ProductArguments arguments) {
@@ -1778,7 +1791,9 @@ void RunEncodedVideo(ProductArguments arguments) {
Check(sample->GetSampleTime(×tamp), "decoded-timestamp");
Check(sample->GetSampleDuration(&duration), "decoded-duration");
if (timestamp < 0 || duration <= 0) Fail("decoded-timing", "decoder did not preserve source timing");
- submit(OwnDecodedTexture(device, sample, width, height, stride), width, height,
+ const auto area = h264->DisplayArea();
+ submit(OwnDecodedTexture(device, sample, width, height, stride, area),
+ area.right - area.left, area.bottom - area.top,
static_cast(timestamp), static_cast(duration), h264->ColorSpace());
});
} else {
@@ -1855,24 +1870,17 @@ void RunVideoCapture(ProductArguments arguments) {
EnableFastCaptureUpdates(capture_session);
piik::capture::CaptureBorder border;
- UniqueHandle shutdown(CreateEventW(nullptr, TRUE, FALSE, nullptr));
- UniqueHandle frame_ready(CreateEventW(nullptr, FALSE, FALSE, nullptr));
- if (shutdown.get() == nullptr || frame_ready.get() == nullptr) {
+ auto shutdown = std::make_shared(
+ CreateEventW(nullptr, TRUE, FALSE, nullptr));
+ auto frame_ready = std::make_shared(
+ CreateEventW(nullptr, FALSE, FALSE, nullptr));
+ if (!*shutdown || !*frame_ready) {
Check(HRESULT_FROM_WIN32(GetLastError()), "capture-events");
}
- std::atomic item_closed{false};
auto frame_token = pool.FrameArrived(
- [event = frame_ready.get()](const Direct3D11CaptureFramePool&,
- const winrt::Windows::Foundation::IInspectable&) {
- SetEvent(event);
- });
- auto closed_token = item.Closed(
- [event = shutdown.get(), &item_closed](const GraphicsCaptureItem&,
- const winrt::Windows::Foundation::IInspectable&) {
- item_closed.store(true);
- SetEvent(event);
- });
+ piik::capture::CaptureEventHandler(frame_ready));
+ auto closed_token = item.Closed(piik::capture::CaptureEventHandler(shutdown));
std::vector> workers;
std::atomic active_status_written{false};
std::mutex failure_mutex;
@@ -1882,10 +1890,10 @@ void RunVideoCapture(ProductArguments arguments) {
std::lock_guard lock(failure_mutex);
if (!failure) failure = error;
}
- SetEvent(shutdown.get());
+ SetEvent(shutdown->get());
};
auto cleanup = [&]() noexcept {
- SetEvent(shutdown.get());
+ SetEvent(shutdown->get());
writer.Stop();
for (auto& worker : workers) worker->Stop();
try {
@@ -1942,8 +1950,8 @@ void RunVideoCapture(ProductArguments arguments) {
refresh_input = false;
};
const HANDLE window_waits[] = {
- process.get(), shutdown.get(), frame_ready.get()};
- const HANDLE display_waits[] = {shutdown.get(), frame_ready.get()};
+ process.get(), shutdown->get(), frame_ready->get()};
+ const HANDLE display_waits[] = {shutdown->get(), frame_ready->get()};
bool source_was_minimized = false;
for (;;) {
if (window_target && IsIconic(reinterpret_cast(
@@ -1976,10 +1984,8 @@ void RunVideoCapture(ProductArguments arguments) {
std::lock_guard lock(failure_mutex);
if (failure) std::rethrow_exception(failure);
}
- if (item_closed.load()) {
- Fail("capture-closed", "selected source stopped capture");
- }
- Fail("capture-stopped", "source capture stopped");
+ // A worker failure was handled above; the other live wake is item.Closed.
+ Fail("capture-closed", "selected source stopped capture");
}
if (wait == WAIT_TIMEOUT) {
// An explicit output/control request may reuse a quiet source; silence is not EOF.
diff --git a/native/capture/windows/output_worker.test.cpp b/native/capture/windows/output_worker.test.cpp
index 2c85e316d..9c2072c28 100644
--- a/native/capture/windows/output_worker.test.cpp
+++ b/native/capture/windows/output_worker.test.cpp
@@ -1,11 +1,14 @@
// Exercise the production worker with a synthetic codec at its existing link
-// boundary. WARP supplies only the device-status API; no screen or audio is used.
+// boundary. WARP also checks decoded-surface copies; no screen or audio is used.
#define wmain capture_main
#include "main.cpp"
#undef wmain
#include
+#include
+#include
#include
+#include
namespace piik::capture::windows {
class AdaptiveEncoder::Impl final {
@@ -72,6 +75,7 @@ void CheckWorkerFailureGeneration(ID3D11Device* device, bool replace) {
auto input = std::make_shared();
input->width = 1280;
input->height = 720;
+ input->presentation = {1280, 720};
input->timestamp = 333'333;
worker.Submit(input);
assert(began.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
@@ -115,13 +119,51 @@ void CheckPrimaryFailureDetails() {
assert(rejected);
}
+void CheckWorkerDimensions(ID3D11Device* device, SIZE content, SIZE expected) {
+ HANDLE read = nullptr, write = nullptr;
+ assert(CreatePipe(&read, &write, nullptr, 4096));
+ UniqueHandle input_pipe(read), output_pipe(write);
+ ProtocolWriter writer(write);
+ std::promise output;
+ auto delivered = output.get_future();
+ OutputWorker worker(0, VideoProfile{}, OutputKind::h264, device, writer,
+ [](const VideoProfile&) -> std::unique_ptr {
+ return std::make_unique(nullptr, std::shared_future{});
+ }, nullptr, true, [&](const EncodedAccessUnit&) { output.set_value(); },
+ [&](std::exception_ptr error) { output.set_exception(error); });
+ auto frame = std::make_shared();
+ frame->width = static_cast(content.cx);
+ frame->height = static_cast(content.cy);
+ frame->presentation = content;
+ frame->timestamp = 333'333;
+ worker.Submit(frame);
+ assert(delivered.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
+ delivered.get();
+ worker.Stop();
+ worker.Join();
+ std::array packet{};
+ DWORD received = 0;
+ assert(ReadFile(read, packet.data(), static_cast(packet.size()), &received, nullptr));
+ assert(received == packet.size());
+ assert((packet[24] << 8 | packet[25]) == expected.cx);
+ assert((packet[26] << 8 | packet[27]) == expected.cy);
+}
+
void CheckH264ProfileCompatibility() {
const VideoProfile profile;
- for (const auto* value : {"42c01f", "42e01f", "42E01F", "4d801f", "58c01f"})
- assert(profile.accepts_h264_profile_level_id(value));
+ for (const auto* value : {"42c01f", "42e01f", "42E01F", "4d801f", "58c01f",
+ "42c00a", "42c016", "42e01e", "42c02a", "42c033", "42c034"})
+ assert(SupportsH264ProfileLevelId(value));
for (const auto* value : {"42001f", "4d001f", "64001f", "42e11f", "42e01", "42e01fg",
- "42e0gg", "42e034", "42e01e", " 42c01"})
- assert(!profile.accepts_h264_profile_level_id(value));
+ "42e0gg", "42e035", "42e03c", "42e000", " 42c01"})
+ assert(!SupportsH264ProfileLevelId(value));
+ Runtime runtime;
+ for (const auto size : {SIZE{1280, 720}, SIZE{3840, 2160}, SIZE{2160, 3840}}) {
+ const auto type = CreateOutputType(VideoProfile{
+ static_cast(size.cx), static_cast(size.cy), 60, 12'000'000});
+ UINT32 level = 0;
+ assert(type->GetUINT32(MF_MT_MPEG2_LEVEL, &level) == MF_E_ATTRIBUTENOTFOUND);
+ }
HANDLE read = nullptr, write = nullptr;
assert(CreatePipe(&read, &write, nullptr, 4096));
@@ -185,9 +227,10 @@ void CheckEncoderCandidates() {
class CadenceFixtureEncoder final : public VideoEncoder {
public:
- CadenceFixtureEncoder(std::chrono::milliseconds latency, bool dropped, bool cold_only)
+ CadenceFixtureEncoder(std::chrono::milliseconds latency, UINT32 dropped, bool cold_only,
+ UINT32& calls)
: VideoEncoder(OutputKind::h264, "cadence-fixture", "cadence-fixture"),
- latency_(latency), dropped_(dropped), cold_only_(cold_only) {}
+ latency_(latency), dropped_(dropped), cold_only_(cold_only), calls_(calls) {}
EncodedAccessUnit Encode(ID3D11Texture2D*, UINT64 timestamp, bool key,
EncoderClock::time_point deadline) override {
@@ -195,15 +238,15 @@ class CadenceFixtureEncoder final : public VideoEncoder {
++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}};
+ return calls_ <= dropped_ ? EncodedAccessUnit{timestamp, key, {}} : EncodedAccessUnit{timestamp, key, {1, 2, 3}};
}
void SetBitrate(UINT32) override {}
private:
std::chrono::milliseconds latency_;
- bool dropped_;
+ UINT32 dropped_;
bool cold_only_;
- unsigned calls_ = 0;
+ UINT32& calls_;
};
void CheckAutoCadenceProbe(ID3D11Device* device) {
@@ -213,22 +256,33 @@ void CheckAutoCadenceProbe(ID3D11Device* device) {
VideoProfile profile;
profile.width = 64;
profile.height = 64;
- auto probe = [&](std::chrono::milliseconds latency, bool dropped,
+ UINT32 calls = 0;
+ auto probe = [&](std::chrono::milliseconds latency, UINT32 dropped,
EncoderClock::time_point deadline, bool cold_only = false) {
- return SustainsEncodedCadence([=](const VideoProfile&) -> std::unique_ptr {
- return std::make_unique(latency, dropped, cold_only);
+ calls = 0;
+ return SustainsEncodedCadence([=, &calls](const VideoProfile&) -> std::unique_ptr {
+ return std::make_unique(latency, dropped, cold_only, calls);
}, 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));
+ assert(!probe(std::chrono::milliseconds(0), UINT32_MAX, deadline));
+ const UINT32 warmup = (profile.frame_rate + 1) / 2;
+ // Once missing frames exceed the allowance, even perfect remaining frames cannot pass.
+ assert(calls == warmup + CadenceLagFrames(profile.frame_rate) + 1);
+ // Exactly the allowed loss must still complete and pass the original measurement.
+ assert(probe(std::chrono::milliseconds(0), warmup + CadenceLagFrames(profile.frame_rate), deadline));
+ assert(calls == warmup + profile.frame_rate);
// Cold startup must not reject an encoder that sustains the measured cadence.
- assert(probe(std::chrono::milliseconds(220), false, deadline, true));
+ assert(probe(std::chrono::milliseconds(220), 0, deadline, true));
// Sustained slow encoding still fails after warmup.
- assert(!probe(std::chrono::milliseconds(300), false, deadline));
+ assert(!probe(std::chrono::milliseconds(300), 0, deadline));
+ profile.frame_rate = 1;
+ assert(!probe(std::chrono::milliseconds(0), UINT32_MAX, deadline));
+ assert(probe(std::chrono::milliseconds(0), 0, deadline));
bool expired = false;
try {
- probe(std::chrono::milliseconds(0), false, EncoderClock::time_point::min());
+ probe(std::chrono::milliseconds(0), 0, EncoderClock::time_point::min());
} catch (const GateFailure& error) {
expired = error.stage() == "codec-probe-timeout";
}
@@ -278,19 +332,129 @@ void CheckSurfaceSample(ID3D11Device* device) {
assert(time == 1'000'000 && duration == 333'333);
}
+void CheckDecodedDisplayArea(ID3D11Device* device) {
+ Runtime runtime;
+ DeviceContext context;
+ context.device = device;
+ device->GetImmediateContext(&context.context);
+ auto check_red = [&](ID3D11Texture2D* texture) {
+ D3D11_TEXTURE2D_DESC description{};
+ texture->GetDesc(&description);
+ assert(description.Width == 3840 && description.Height == 2160 &&
+ description.Format == DXGI_FORMAT_NV12);
+ description.Usage = D3D11_USAGE_STAGING;
+ description.BindFlags = 0;
+ description.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
+ ComPtr staging;
+ Check(device->CreateTexture2D(&description, nullptr, &staging), "fixture-readback");
+ context.context->CopyResource(staging.Get(), texture);
+ D3D11_MAPPED_SUBRESOURCE mapped{};
+ Check(context.context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped), "fixture-map");
+ const auto* pixels = static_cast(mapped.pData);
+ for (UINT y : {0u, 1080u, 2159u}) {
+ for (UINT x : {0u, 1920u, 3839u}) {
+ const size_t uv = static_cast(2160 + y / 2) * mapped.RowPitch + (x & ~1u);
+ if (pixels[static_cast(y) * mapped.RowPitch + x] <= 60 ||
+ pixels[uv] >= 130 || pixels[uv + 1] <= 180)
+ std::cerr << "aperture pixel " << x << ',' << y << " YUV="
+ << unsigned(pixels[static_cast(y) * mapped.RowPitch + x]) << ','
+ << unsigned(pixels[uv]) << ',' << unsigned(pixels[uv + 1]) << '\n';
+ assert(pixels[static_cast(y) * mapped.RowPitch + x] > 60);
+ assert(pixels[uv] < 130 && pixels[uv + 1] > 180);
+ }
+ }
+ context.context->Unmap(staging.Get(), 0);
+ };
+
+ const auto path = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path() /
+ "fixtures" / "h264-padding" / "aperture.h264";
+ std::ifstream file(path, std::ios::binary);
+ assert(file);
+ piik::capture::InputEnvelope input;
+ input.kind = 2;
+ input.recovery = true;
+ input.width = 3840;
+ input.height = 2160;
+ input.duration = 10'000'000 / 24;
+ input.data = std::vector(std::istreambuf_iterator(file), {});
+ unsigned decoded = 0;
+ piik::capture::H264Decoder decoder(nullptr);
+ for (unsigned index = 0; index < 4; ++index) {
+ input.timestamp = (index + 1) * input.duration;
+ decoder.Decode(input, [&](IMFSample* sample, UINT32 width, UINT32 height, LONG stride) {
+ const auto area = decoder.DisplayArea();
+ assert(width == 3856 && height == 2176 && area.left == 0 && area.top == 0 &&
+ area.right == 3840 && area.bottom == 2160);
+ check_red(OwnDecodedTexture(context, sample, width, height, stride, area).Get());
+ ++decoded;
+ });
+ }
+ assert(decoded > 0);
+
+ // Exercise the DXGI-buffer path with known NV12 padding as well.
+ constexpr UINT width = 3872, height = 2176;
+ std::vector pixels(width * height * 3 / 2);
+ for (UINT y = 0; y < height; ++y) {
+ for (UINT x = 0; x < width; ++x) {
+ pixels[y * width + x] = x >= 16 && x < 3856 && y >= 8 && y < 2168 ? 81 : 41;
+ if (y < height / 2) {
+ const bool red = x >= 16 && x < 3856 && y >= 4 && y < 1084;
+ pixels[(height + y) * width + x] = red ? (x % 2 ? 240 : 90) : (x % 2 ? 110 : 240);
+ }
+ }
+ }
+ auto texture = UploadDecodedNV12(context, pixels.data(), width, height);
+ auto sample = CreateSurfaceSample(texture.Get(), 1'000'000, 416'666);
+ check_red(OwnDecodedTexture(context, sample.Get(), width, height, width,
+ RECT{16, 8, 3856, 2168}).Get());
+}
+
int main() {
VideoProfile profile{2560, 1440, 60, 12'000'000};
ValidateVideoProfile(profile);
- assert(profile.h264_level() == 51 && profile.profile_level_id() == "42c033");
- profile = VideoProfile{1920, 1080, 60, 8'000'000};
- assert(profile.h264_level() == 42);
- profile = VideoProfile{3840, 2160, 30, 12'000'000};
- try {
- ValidateVideoProfile(profile);
- assert(false);
- } catch (const GateFailure& error) {
- assert(error.stage() == "argument-profile");
+ for (const auto& requested : {VideoProfile{1280, 720, 1, 100'000},
+ VideoProfile{1280, 720, 5, 800'000},
+ VideoProfile{3840, 2160, 60, 50'000'000},
+ VideoProfile{1920, 1080, 240, 240'000'000},
+ VideoProfile{2560, 1440, 144, 100'000'000},
+ VideoProfile{5120, 1440, 60, 100'000'000},
+ VideoProfile{4096, 2304, 56, 80'000'000}}) {
+ ValidateVideoProfile(requested);
+ }
+ for (const auto& requested : {VideoProfile{1280, 720, 0, 100'000},
+ VideoProfile{1280, 720, 1, 99'999},
+ VideoProfile{3840, 2160, 60, 240'000'001},
+ VideoProfile{1920, 1080, 241, 100'000'000}}) {
+ try {
+ ValidateVideoProfile(requested);
+ assert(false);
+ } catch (const GateFailure& error) {
+ assert(error.stage() == "argument-profile");
+ }
+ }
+ for (const SIZE size : {SIZE{1920, 1200}, SIZE{720, 1280}, SIZE{4, 4},
+ SIZE{3840, 2160}, SIZE{2160, 3840}, SIZE{3000, 2764}}) {
+ ValidateVideoProfile(VideoProfile{static_cast(size.cx), static_cast(size.cy), 60, 3'000'000});
+ }
+ for (const SIZE size : {SIZE{0, 720}, SIZE{2, 720}, SIZE{1280, 2}, SIZE{1279, 720},
+ SIZE{1280, 719}, SIZE{8194, 100}, SIZE{5120, 2880},
+ SIZE{4096, 2306}, SIZE{6562, 1438}}) {
+ try {
+ ValidateVideoProfile(VideoProfile{static_cast(size.cx), static_cast(size.cy), 30, 3'000'000});
+ assert(false);
+ } catch (const GateFailure& error) {
+ assert(error.stage() == "argument-profile");
+ }
}
+ assert(piik::capture::ValidVideoFormat(3840, 2160, 30));
+ assert(piik::capture::ValidVideoFormat(3840, 2160, 60));
+ assert(piik::capture::ValidVideoFormat(2160, 3840, 60));
+ assert(!piik::capture::ValidVideoFormat(3840, 2160, 65));
+ assert(!piik::capture::ValidVideoFormat(UINT32_MAX, UINT32_MAX, UINT32_MAX));
+ assert(piik::capture::ValidCodedVideoSize(3840, 2176));
+ assert(piik::capture::ValidCodedVideoSize(2176, 3840));
+ assert(!piik::capture::ValidCodedVideoSize(3840, 3840));
+ assert(!piik::capture::ValidCodedVideoSize(UINT32_MAX, UINT32_MAX));
CheckEncoderCandidates();
CheckH264ProfileCompatibility();
CheckPrimaryFailureDetails();
@@ -299,7 +463,11 @@ int main() {
Check(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0, nullptr, 0,
D3D11_SDK_VERSION, &device, nullptr, nullptr), "fixture-warp");
CheckSurfaceSample(device.Get());
+ CheckDecodedDisplayArea(device.Get());
CheckAutoCadenceProbe(device.Get());
CheckWorkerFailureGeneration(device.Get(), false);
CheckWorkerFailureGeneration(device.Get(), true);
+ CheckWorkerDimensions(device.Get(), {1080, 1920}, {404, 720});
+ CheckWorkerDimensions(device.Get(), {3440, 1440}, {1280, 534});
+ CheckWorkerDimensions(device.Get(), {640, 480}, {640, 480});
}
diff --git a/native/capture/windows/video_bounds.h b/native/capture/windows/video_bounds.h
new file mode 100644
index 000000000..1b8316cf9
--- /dev/null
+++ b/native/capture/windows/video_bounds.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#include
+
+namespace piik::capture {
+
+inline constexpr uint32_t kMaxVideoDimension = 8192;
+inline constexpr uint64_t kMaxVideoPixels = 36'864 * 16 * 16;
+inline constexpr uint32_t kMaxVideoFramerate = 240;
+inline constexpr uint32_t kMaxVideoBitrate = 240'000'000;
+// H.264 Level 5.2 coded storage, including padding outside the visible crop.
+inline constexpr uint32_t kMaxCodedVideoDimension = 8192;
+inline constexpr uint64_t kMaxCodedVideoPixels = 36'864 * 16 * 16;
+
+inline bool ValidVideoSize(uint32_t width, uint32_t height) {
+ return width >= 2 && width <= kMaxVideoDimension && !(width & 1) &&
+ height >= 2 && height <= kMaxVideoDimension && !(height & 1) &&
+ static_cast((width + 15) / 16) * ((height + 15) / 16) * 256 <= kMaxVideoPixels;
+}
+
+inline bool ValidVideoFormat(uint32_t width, uint32_t height, uint32_t fps) {
+ return ValidVideoSize(width, height) && fps >= 1 && fps <= kMaxVideoFramerate &&
+ static_cast((width + 15) / 16) * ((height + 15) / 16) * fps <= 2'073'600;
+}
+
+inline bool ValidCodedVideoSize(uint32_t width, uint32_t height) {
+ return width >= 2 && width <= kMaxCodedVideoDimension && !(width & 1) &&
+ height >= 2 && height <= kMaxCodedVideoDimension && !(height & 1) &&
+ static_cast(width) * height <= kMaxCodedVideoPixels;
+}
+
+} // namespace piik::capture
diff --git a/native/capture/windows/vp8_decoder.h b/native/capture/windows/vp8_decoder.h
index cb57c4eb4..d7246aac8 100644
--- a/native/capture/windows/vp8_decoder.h
+++ b/native/capture/windows/vp8_decoder.h
@@ -1,5 +1,7 @@
#pragma once
+#include "video_bounds.h"
+
#include
#include
@@ -37,7 +39,7 @@ class Vp8Decoder final {
vpx_codec_stream_info_t info{};
info.sz = sizeof(info);
if (vpx_codec_peek_stream_info(vpx_codec_vp8_dx(), data.data(), static_cast(data.size()), &info) != VPX_CODEC_OK ||
- info.w < 2 || info.h < 2 || info.w > 2560 || info.h > 1440 || (info.w & 1) || (info.h & 1)) {
+ !ValidVideoSize(info.w, info.h)) {
throw std::runtime_error("VP8 decoder input dimensions exceed the source bound");
}
}
@@ -48,7 +50,7 @@ class Vp8Decoder final {
const auto* image = vpx_codec_get_frame(&codec_, &iterator);
if (!image) return {};
const uint32_t width = image->d_w, height = image->d_h;
- if (width < 2 || height < 2 || width > 2560 || height > 1440 || (width & 1) || (height & 1) ||
+ if (!ValidVideoSize(width, height) ||
image->fmt != VPX_IMG_FMT_I420 || image->stride[0] < static_cast(width) ||
image->stride[1] < static_cast(width / 2) || image->stride[2] < static_cast(width / 2)) {
throw std::runtime_error("VP8 decoder output format is unsupported");
diff --git a/native/fixtures/h264-padding/README.md b/native/fixtures/h264-padding/README.md
new file mode 100644
index 000000000..7aa24fe31
--- /dev/null
+++ b/native/fixtures/h264-padding/README.md
@@ -0,0 +1,22 @@
+# H.264 display aperture fixture
+
+`aperture.h264` is one generated Constrained Baseline frame with 3856×2176
+encoded storage and a 3840×2160 visible aperture at (0, 0). The visible picture
+is red; the right/bottom padding is blue. It contains no screen recording or user data.
+
+The Windows output-worker test decodes it through Media Foundation and verifies
+the dimensions and NV12 pixels handed to downstream encoders. The macOS geometry
+self-test uses its SPS/PPS to check CoreMedia's format interpretation without
+allocating a hardware decoder. Both run through `npm run check:native` on their
+respective platforms.
+
+To regenerate with FFmpeg and libx264:
+
+```sh
+ffmpeg -f lavfi -i color=c=blue:s=3856x2176:r=24 \
+ -vf drawbox=x=0:y=0:w=3840:h=2160:color=red:t=fill \
+ -frames:v 1 -c:v libx264 -profile:v baseline -level:v 5.1 \
+ -preset medium -tune zerolatency -threads 2 \
+ -bsf:v h264_metadata=crop_right=16:crop_bottom=16 \
+ aperture.h264
+```
diff --git a/native/fixtures/h264-padding/aperture.h264 b/native/fixtures/h264-padding/aperture.h264
new file mode 100644
index 000000000..e76960b61
Binary files /dev/null and b/native/fixtures/h264-padding/aperture.h264 differ
diff --git a/package.json b/package.json
index 556aa3c8e..7524a761b 100644
--- a/package.json
+++ b/package.json
@@ -17,10 +17,12 @@
"package:app-candidate": "node scripts/package-app-candidate.mjs",
"check:go": "node scripts/check-go.mjs",
"check:go-core": "node scripts/check-go.mjs --core",
+ "check:go-vuln": "go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./cmd/piik-app ./cmd/piik-server",
"check:go-race": "node scripts/check-go.mjs --race",
"check:container": "node scripts/check-container.mjs",
"check:native": "node scripts/check-go.mjs --capture-only",
"gate:app-media": "tsx scripts/app-media-gate.ts",
+ "gate:network-lab": "tsx scripts/network-lab.ts",
"gate:app-native-host": "tsx scripts/app-native-host-gate.ts",
"gate:app-browser-fanout": "tsx scripts/app-browser-fanout-gate.ts",
"gate:embedded-sfu": "tsx scripts/embedded-sfu-gate.ts",
diff --git a/scripts/app-browser-fanout-gate.ts b/scripts/app-browser-fanout-gate.ts
index e534a31e6..3f0715fbb 100644
--- a/scripts/app-browser-fanout-gate.ts
+++ b/scripts/app-browser-fanout-gate.ts
@@ -8,6 +8,7 @@ import {
waitForSample, waitForVersion, type PageHandle,
} from "./browser-gate-harness";
import { readAppEndpoint } from "./app-gate-endpoint";
+import { fittedCaptureSize, matchesCaptureSize, parseVideoGateSize } from "./media-gate-geometry";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build/go-check");
@@ -17,6 +18,8 @@ const BUILD_ROOT = join(ROOT, "build/go-check");
const LAN_ADDRESS = "192.0.2.1";
const initialAudio = process.env.PIIK_CLIENT_GATE_AUDIO !== "false";
+const punchRetry = process.env.PIIK_CLIENT_PUNCH_GATE === "true";
+const captureSize = parseVideoGateSize(process.env.PIIK_CLIENT_GATE_VIDEO_SIZE || "1920x1080");
const probe = String.raw`(() => {
localStorage.setItem('piik:ui-lang', 'en');
localStorage.setItem('piik:ui-mode', 'text');
@@ -28,18 +31,33 @@ const probe = String.raw`(() => {
catch (error) { errors.push(String(error)); throw error; }
};
const PC = RTCPeerConnection, WS = WebSocket;
+ let rejectFirstEdge = ${punchRetry};
window.RTCPeerConnection = class extends PC { constructor(...args) { super(...args); pcs.push(this); } };
window.WebSocket = class extends WS {
constructor(...args) { super(...args); if (String(args[0]).includes('/control')) {
sockets.push(this);
} }
- send(raw) { if (this.url.includes('/control')) { const value = JSON.parse(raw); requests.push(value.type); } super.send(raw); }
+ send(raw) {
+ if (this.url.includes('/control')) {
+ const value = JSON.parse(raw); requests.push(value.type);
+ // Fail one candidate through the ordinary operation-error boundary.
+ // The real room controller must authorize the next preparation.
+ if (rejectFirstEdge && value.type === 'prepare-edge') {
+ rejectFirstEdge = false;
+ queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', {data: JSON.stringify({
+ version:value.version, id:value.id, type:'request-failed', code:'operation-failed',
+ })})));
+ return;
+ }
+ }
+ super.send(raw);
+ }
};
let audio = ${initialAudio}, captures = 0;
navigator.mediaDevices.getDisplayMedia = async () => {
captures++;
- const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080;
- const context = canvas.getContext('2d'); let frame = 0;
+ const canvas = document.createElement('canvas'); canvas.width = ${captureSize.width}; canvas.height = ${captureSize.height};
+ const context = canvas.getContext('2d'); context.scale(canvas.width/1920,canvas.height/1080); let frame = 0;
const draw = () => { context.fillStyle = '#14242a'; context.fillRect(0,0,1920,1080);
context.fillStyle = '#68dab3'; context.fillRect((frame++ * 17)%1800,100,120,400);
context.fillStyle = '#ffffff'; context.font = '80px sans-serif'; context.fillText(String(frame),100,700); };
@@ -80,7 +98,7 @@ const probe = String.raw`(() => {
}
}); }
const video = document.querySelector('video');
- return { outbound, encoded, received, captures, videoStats, feedback, width: video?.videoWidth || 0,
+ return { outbound, encoded, received, captures, videoStats, feedback, width: video?.videoWidth || 0, height: video?.videoHeight || 0,
requests, errors, peers: pcs.map((p) => ({state:p.connectionState, tracks:p.getSenders().map((s)=>s.track?.kind)})),
frames: video?.getVideoPlaybackQuality().totalVideoFrames || 0,
sharing: Boolean(document.querySelector('button[aria-label="Stop sharing"]')),
@@ -134,6 +152,8 @@ async function startRoomServer(
LISTEN_HOST: "127.0.0.1",
PUBLIC_BASE_URL: `http://${LAN_ADDRESS}:${port}`,
ALLOWED_ORIGINS: origins.join(","),
+ ...(punchRetry ? { STUN_URLS: "stun:127.0.0.1:3478", STUN_LISTEN_HOST: "127.0.0.1",
+ NAT_PREDICTION_ENABLED: "true" } : {}),
},
});
child.stdout.resume();
@@ -206,6 +226,12 @@ async function main(): Promise {
await click(host, 'button[aria-controls="host-advanced-door"]');
stage = "share-codec";
await click(host, 'button[aria-label^="H264"]');
+ await evaluate(cdp, host, `(() => {
+ const width = document.querySelector('.lr-video-size input[name="width"]');
+ const height = document.querySelector('.lr-video-size input[name="height"]');
+ width.focus(); width.value = '${captureSize.width}';
+ height.focus(); height.value = '${captureSize.height}'; height.blur();
+ })()`, Date.now()+5000);
stage = "share-picker";
await click(host, 'button.lr-tv-big.is-action');
// This arm measures Native ingress. Browser capture can start without App
@@ -240,10 +266,21 @@ async function main(): Promise {
await waitForSample(() => read(viewer), (s) => s.width > 0 && s.frames >= 30, 30000);
}
stage = "shared-encode";
- await waitForSample(() => read(viewers[0]!), (s) => s.width===1920 && s.outbound===0, 30000);
- await waitForSample(() => read(viewers[1]!), (s) => s.width===1920 && s.outbound===0, 30000);
+ for (const viewer of viewers) await waitForSample(() => read(viewer),
+ (s) => matchesCaptureSize(s.width, s.height, captureSize) && s.outbound===0, 30000);
checks.host = await read(host);
checks.viewers = await Promise.all(viewers.map(read));
+ if (punchRetry) {
+ // Loopback mappings are deliberately ineligible. Both real App controls
+ // must prepare, decline and continue ordinary ICE without losing media.
+ const source = checks.host as Awaited>;
+ const target = (checks.viewers as Awaited>[])[0];
+ if (!source.requests.includes("prepare-punch") || !target.requests.includes("prepare-punch") ||
+ source.requests.includes("run-punch") || target.requests.includes("run-punch")) {
+ throw new Error("Authorized retry did not decline its ineligible Native preparation");
+ }
+ checks.nativePreparationDeclined = true;
+ }
if ((checks.host as any).outbound !== 1 || !(checks.viewers as any[]).every((v) => v.outbound === 0)) {
throw new Error("Shared encode count did not match the Native fanout");
}
@@ -255,7 +292,12 @@ async function main(): Promise {
if (!(checks.viewerFps as number[]).every((fps) => fps>=27)) throw new Error("Viewer delivery did not sustain the 30 fps source");
stage = "quality";
await click(host, 'button[aria-label="720p"]');
- await waitForSample(() => read(viewers[0]!), (s) => s.width === 1280, 20000);
+ // Canvas capture can crop when applyConstraints changes its aspect. This
+ // gate checks fanout of the actual input; --display in the pool probe owns
+ // real display-capture geometry.
+ await waitForSample(async () => ({ source: await read(host), viewer: await read(viewers[0]!) }),
+ ({ source, viewer }) => source.width > 0 && source.height > 0 &&
+ matchesCaptureSize(viewer.width, viewer.height, fittedCaptureSize(source, 1280, 720)), 20000);
checks.liveQuality = true;
stage = "pause-resume";
await click(host, 'button[aria-label="Pause sharing"]');
@@ -297,7 +339,7 @@ async function main(): Promise {
const cleanup = await cleanupRun({cdp, native:app, chrome, server, profile, ports:[appPort,debugPort,...(nativePort?[nativePort]:[])]});
const result = {passed: error===null && Object.values(cleanup).every(Boolean) && relayCleanup.nativeExited && relayCleanup.portsClosed, stage, checks, error, cleanup};
await mkdir(BUILD_ROOT,{recursive:true});
- await writeFile(join(BUILD_ROOT,"browser-fanout.json"), JSON.stringify(result,null,2));
+ await writeFile(join(BUILD_ROOT,punchRetry ? "browser-fanout-punch.json" : "browser-fanout.json"), JSON.stringify(result,null,2));
process.stdout.write(JSON.stringify(result)+"\n");
if (!result.passed) process.exitCode=1;
}
diff --git a/scripts/app-icons.mjs b/scripts/app-icons.mjs
index 5c6af9e43..bee44a9c6 100644
--- a/scripts/app-icons.mjs
+++ b/scripts/app-icons.mjs
@@ -105,7 +105,7 @@ function writeLinuxAssets(packageRoot, entries) {
"Exec=piik-app",
"TryExec=piik-app",
"Icon=piik-app",
- "Terminal=false",
+ "Terminal=true",
"Categories=Network;Utility;",
"StartupNotify=true",
"",
diff --git a/scripts/app-media-gate.ts b/scripts/app-media-gate.ts
index 27b1ad6d3..a4b296325 100644
--- a/scripts/app-media-gate.ts
+++ b/scripts/app-media-gate.ts
@@ -3,6 +3,7 @@ import { copyFile, mkdir, mkdtemp } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
+import { fittedCaptureSize, matchesCaptureSize } from "./media-gate-geometry";
import {
CdpConnection,
@@ -266,7 +267,7 @@ async function browserMediaGate(input: {
sourceTitle: string;
sourceKind: "window" | "display";
stunUrls: string;
-}): Promise {
+}, geometry: { fit: typeof fittedCaptureSize; matches: typeof matchesCaptureSize }): Promise {
let socket: WebSocket | null = null;
const peers: RTCPeerConnection[] = [];
const result: MediaEvidence = {
@@ -355,7 +356,7 @@ async function browserMediaGate(input: {
rejectRequest(new Error("Native request timed out: " + type));
}, 8_000);
pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
- socket!.send(JSON.stringify({ version: 9, id, type, ...fields }));
+ socket!.send(JSON.stringify({ version: 10, id, type, ...fields }));
});
};
socket.onmessage = (event) => {
@@ -436,11 +437,11 @@ async function browserMediaGate(input: {
encoderIndex: encoder.index,
edgeCapacity: 2,
profile: {
- resolution: "720p",
+ resolution: { width: 2560, height: 1440 },
maxFramerate: 30,
maxBitrate: 3_000_000,
- degradationPreference: "balanced",
- screenAudioQuality: "music",
+ degradationPreference: "maintain-resolution",
+ maxAudioBitrate: 128000,
},
});
if (started.shareId !== shareId || started.audio !== result.audioAvailable) {
@@ -495,6 +496,11 @@ async function browserMediaGate(input: {
await peer.addIceCandidate(candidate);
}
const answer = await peer.createAnswer();
+ // This fixture is an unchanged-Opus receiver, like the product Viewer.
+ // Declare its receive capacity; the offer's fmtp is the opposite direction.
+ const opus = answer.sdp?.match(/^a=rtpmap:(\d+) opus\/48000\/2\r?$/m)?.[1];
+ if (opus) answer.sdp = answer.sdp!.replace(new RegExp(`^(a=fmtp:${opus} [^\\r\\n]*)`, "m"),
+ "$1;stereo=1;maxaveragebitrate=510000");
await peer.setLocalDescription(answer);
await request("edge-answer", {
shareId,
@@ -552,16 +558,16 @@ async function browserMediaGate(input: {
? Math.min(...result.edgeAudioBytes)
: 0;
const videos = [...edges.values()].map((edge) => edge.video);
- result.width = videos.every((video) => video.videoWidth === 1280) ? 1280 : 0;
- result.height = videos.every((video) => video.videoHeight === 720) ? 720 : 0;
+ result.width = videos.every((video) => video.videoWidth === videos[0]!.videoWidth && video.videoWidth <= 2560) ? videos[0]!.videoWidth : 0;
+ result.height = videos.every((video) => video.videoHeight === videos[0]!.videoHeight && video.videoHeight <= 1440) ? videos[0]!.videoHeight : 0;
if (
result.captureActive &&
result.connected &&
result.frames >= 30 &&
(!result.audioAvailable ||
(result.audioPackets > 10 && result.audioEnergy > 0)) &&
- result.width === 1280 &&
- result.height === 720 &&
+ result.width >= 320 &&
+ result.height >= 240 &&
result.nativeCandidateTypes.includes("srflx")
) {
break;
@@ -574,25 +580,28 @@ async function browserMediaGate(input: {
result.frames < 30 ||
(result.audioAvailable &&
(result.audioPackets <= 10 || result.audioEnergy <= 0)) ||
- result.width !== 1280 ||
- result.height !== 720 ||
+ result.width < 320 ||
+ result.height < 240 ||
!result.nativeCandidateTypes.includes("srflx")
) {
throw new Error("Native H264 did not reach decoded Browser video");
}
const videos = [...edges.values()].map((edge) => edge.video);
+ const sourceSize = { width: result.width, height: result.height };
+ const liveSize = geometry.fit(sourceSize, 1280, 720);
+ const pausedSize = geometry.fit(sourceSize, 854, 480);
const framesBeforeLiveUpdate = Math.min(
...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames),
);
const liveUpdate = await request("update-share", {
shareId,
profile: {
- resolution: "1440p",
+ resolution: { width: 1280, height: 720 },
maxFramerate: 60,
maxBitrate: 12_000_000,
degradationPreference: "maintain-framerate",
- screenAudioQuality: "very-high",
+ maxAudioBitrate: 192000,
},
});
const liveDeadline = performance.now() + 15_000;
@@ -601,7 +610,7 @@ async function browserMediaGate(input: {
...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames),
);
result.liveProfileUpdated = liveUpdate.type === "share-updated" &&
- videos.every((video) => video.videoWidth === 2560 && video.videoHeight === 1440) &&
+ videos.every((video) => geometry.matches(video.videoWidth, video.videoHeight, liveSize)) &&
frames >= framesBeforeLiveUpdate + 10;
if (result.liveProfileUpdated) break;
await new Promise((resolveWait) => window.setTimeout(resolveWait, 100));
@@ -614,11 +623,11 @@ async function browserMediaGate(input: {
const pausedUpdate = await request("update-share", {
shareId,
profile: {
- resolution: "480p",
+ resolution: { width: 854, height: 480 },
maxFramerate: 15,
maxBitrate: 2_000_000,
degradationPreference: "maintain-resolution",
- screenAudioQuality: "saver",
+ maxAudioBitrate: 64000,
},
});
const framesBeforeResume = Math.min(
@@ -631,12 +640,12 @@ async function browserMediaGate(input: {
...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames),
);
result.pausedProfileUpdated = pausedUpdate.type === "share-updated" &&
- videos.every((video) => video.videoWidth === 854 && video.videoHeight === 480) &&
+ videos.every((video) => geometry.matches(video.videoWidth, video.videoHeight, pausedSize)) &&
frames >= framesBeforeResume + 10;
if (result.pausedProfileUpdated) {
result.frames = frames;
- result.width = 854;
- result.height = 480;
+ result.width = videos[0]!.videoWidth;
+ result.height = videos[0]!.videoHeight;
break;
}
await new Promise((resolveWait) => window.setTimeout(resolveWait, 100));
@@ -901,7 +910,7 @@ async function main(): Promise {
sourceTitle: SOURCE_TITLE,
sourceKind,
stunUrls: GATE_STUN_URLS,
- }) + "))((target) => target)",
+ }) + `, { fit: ${fittedCaptureSize.toString()}, matches: ${matchesCaptureSize.toString()} }))((target) => target)`,
Date.now() + 70_000,
);
if (media.error) throw new Error(media.error);
@@ -925,7 +934,7 @@ async function main(): Promise {
(!media.audioAvailable ||
(media.audioPackets > 10 && media.audioEnergy > 0)) &&
media.liveProfileUpdated && media.pausedProfileUpdated &&
- media.width === 854 && media.height === 480 &&
+ media.width > 0 && media.width <= 854 && media.height > 0 && media.height <= 480 &&
media.nativeCandidateTypes.includes("srflx") &&
cleanup.browserExited && cleanup.nativeExited && cleanup.serverClosed &&
cleanup.portsClosed && cleanup.profileRemoved;
diff --git a/scripts/app-native-host-gate.ts b/scripts/app-native-host-gate.ts
index 33b752c5e..68003168c 100644
--- a/scripts/app-native-host-gate.ts
+++ b/scripts/app-native-host-gate.ts
@@ -10,6 +10,7 @@ import { mkdir, mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
+import { fittedCaptureSize, matchesCaptureSize } from "./media-gate-geometry";
import {
CdpConnection,
@@ -31,10 +32,15 @@ const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build", "go-check");
export const SOURCE_TITLE = "Piik Native Gate Source";
-type GateMode = "local" | "cross-nat" | "one-link";
+type GateMode = "local" | "one-link";
type VideoCodec = "h264" | "vp8";
const CODEC_PROBE = `(() => {
+ const fitSize = ${fittedCaptureSize.toString()}, matchesSize = ${matchesCaptureSize.toString()};
+ window.__piikGateFitsProfile = (width, height) => {
+ const video = document.querySelector('video'), source = window.__piikGateSourceSize;
+ return Boolean(video && source && matchesSize(video.videoWidth, video.videoHeight, fitSize(source, width, height)));
+ };
const peers = [], BrowserPeer = RTCPeerConnection;
const sockets = [], BrowserSocket = WebSocket;
window.WebSocket = class extends BrowserSocket {
@@ -76,6 +82,9 @@ interface GateResult {
qualityControlsEnabled: boolean;
livePresetChanges: number;
liveQualityChanged: boolean;
+ fourKQualityChanged: boolean;
+ customQualityChanged: boolean;
+ rateQualityChanges: number;
pausedQualityChanged: boolean;
backgroundProfileRecovery: boolean | null;
sourceWindowRecovery: boolean | null;
@@ -92,7 +101,6 @@ interface GateResult {
remoteRemoteCandidateType: string;
remoteNatPath: boolean;
remotePeerExited: boolean | null;
- reverseSignalTunnelClosed: boolean | null;
sourceFailureEndedShare: boolean | null;
replacementViewerConnected: boolean | null;
replacementViewerFrames: number | null;
@@ -110,7 +118,6 @@ interface RemoteGateOptions {
host: string;
user: string;
key: string;
- signalPort: number;
bindAddress: string | null;
}
@@ -273,18 +280,15 @@ function remoteOptions(): RemoteGateOptions {
const host = process.env.PIIK_REMOTE_HOST?.trim();
const user = process.env.PIIK_REMOTE_USER?.trim() || "root";
const key = process.env.PIIK_REMOTE_SSH_KEY?.trim();
- const signalPortText = process.env.PIIK_REMOTE_SIGNAL_PORT?.trim() || "49721";
- const signalPort = Number(signalPortText);
- if (!host || !key || !Number.isInteger(signalPort) || signalPort < 1024 || signalPort > 65_535) {
+ if (!host || !key) {
throw new Error(
- "PIIK_REMOTE_HOST, PIIK_REMOTE_SSH_KEY, and a valid PIIK_REMOTE_SIGNAL_PORT are required",
+ "PIIK_REMOTE_HOST and PIIK_REMOTE_SSH_KEY are required",
);
}
return {
host,
user,
key,
- signalPort,
bindAddress: process.env.PIIK_REMOTE_BIND_ADDRESS?.trim() || null,
};
}
@@ -574,16 +578,8 @@ async function main(): Promise {
if (process.env.PIIK_CLIENT_NATIVE_HOST_GATE !== "true") {
throw new Error("PIIK_CLIENT_NATIVE_HOST_GATE=true is required");
}
- const crossNat = process.env.PIIK_CLIENT_CROSS_NAT_GATE === "true";
const linkMedia = process.env.PIIK_CLIENT_LINK_MEDIA_GATE === "true";
- if (crossNat && linkMedia) {
- throw new Error("Cross-NAT and one-link gate modes are mutually exclusive");
- }
- const mode: GateMode = linkMedia ? "one-link" : crossNat ? "cross-nat" : "local";
- const gateStunUrls = process.env.PIIK_CLIENT_GATE_STUN_URLS?.trim();
- if (mode === "cross-nat" && !gateStunUrls) {
- throw new Error("PIIK_CLIENT_GATE_STUN_URLS is required for the cross-NAT gate");
- }
+ const mode: GateMode = linkMedia ? "one-link" : "local";
const crashGate =
process.env.PIIK_CLIENT_NATIVE_HOST_CRASH_GATE === "true";
if (crashGate && mode !== "local") {
@@ -597,7 +593,7 @@ async function main(): Promise {
if (requestedCodec !== "auto" && requestedCodec !== "h264" && requestedCodec !== "vp8") {
throw new Error("PIIK_CLIENT_NATIVE_HOST_CODEC must be auto, h264, or vp8");
}
- const remote = mode === "cross-nat" || mode === "one-link" &&
+ const remote = mode === "one-link" &&
Boolean(process.env.PIIK_REMOTE_HOST?.trim() || process.env.PIIK_REMOTE_SSH_KEY?.trim())
? remoteOptions() : null;
const chromePath = process.env.CHROME_PATH?.trim();
@@ -627,7 +623,6 @@ async function main(): Promise {
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let nativePort = 0;
- let remoteTunnel: ChildProcess | null = null;
let stage = "setup";
const result: GateResult = {
passed: false,
@@ -640,6 +635,9 @@ async function main(): Promise {
qualityControlsEnabled: false,
livePresetChanges: 0,
liveQualityChanged: false,
+ fourKQualityChanged: false,
+ customQualityChanged: false,
+ rateQualityChanges: 0,
pausedQualityChanged: false,
backgroundProfileRecovery: mode === "local" && sourceKind === "window" ? false : null,
sourceWindowRecovery: mode === "local" && sourceKind === "window" ? false : null,
@@ -656,7 +654,6 @@ async function main(): Promise {
remoteRemoteCandidateType: "",
remoteNatPath: false,
remotePeerExited: null,
- reverseSignalTunnelClosed: mode === "cross-nat" ? false : null,
sourceFailureEndedShare: mode === "local" ? false : null,
replacementViewerConnected: mode === "local" ? false : null,
replacementViewerFrames: mode === "local" ? 0 : null,
@@ -727,37 +724,12 @@ async function main(): Promise {
...process.env,
PIIK_DEBUG: "",
PIIK_CLIENT_GATE_NO_BROWSER: "true",
- // STUN_URLS reaches only the App's own Pion edge: the in-process
- // room server never reads it, so the cross-NAT arm stays isolated.
- ...(mode === "cross-nat" && gateStunUrls
- ? { STUN_URLS: gateStunUrls }
- : {}),
},
});
app.stderr.resume();
stage = "app-ready";
const appInfo = await readAppEndpoint(app, mode === "one-link");
nativePort = appInfo.endpoint.port;
- if (mode === "cross-nat" && remote) {
- stage = "signaling-tunnel";
- const tunnel = spawn(
- process.env.PIIK_SSH?.trim() || "ssh",
- [
- "-N", "-T",
- ...remoteTransportOptions(remote),
- "-o", "ExitOnForwardFailure=yes",
- "-R", `127.0.0.1:${remote.signalPort}:127.0.0.1:${appPort}`,
- `${remote.user}@${remote.host}`,
- ],
- { stdio: "ignore", windowsHide: true },
- );
- remoteTunnel = tunnel;
- tunnel.on("error", () => undefined);
- await new Promise((resolveTunnel) => setTimeout(resolveTunnel, 300));
- if (tunnel.exitCode !== null) {
- throw new Error("signaling tunnel could not start");
- }
- }
stage = "local-server-ready";
await waitForValue(
async () => {
@@ -823,6 +795,10 @@ async function main(): Promise {
5_000,
);
stage = "pre-share-codec";
+ // Observe the source at the largest current ceiling before testing smaller
+ // ceilings. Native capture no longer pads/upscales every source to 16:9.
+ await clickHostControl(cdp, host, `document.querySelector('button.lr-chip[aria-label="1440p"]')`);
+ await clickHostControl(cdp, host, `document.querySelector('button[name="degradationPreference"][value="maintain-resolution"]')`);
await clickHostControl(cdp, host, `document.querySelector('.lr-sharing-technical > summary')`);
await clickHostControl(cdp, host,
`([...document.querySelectorAll('button.lr-chip')].find((button) =>
@@ -893,19 +869,14 @@ async function main(): Promise {
}
if (remote) {
stage = "remote-viewer";
- const signalUrl = mode === "one-link"
- ? new URL("/signal", appInfo.publicOrigin!).toString().replace(/^http/, "ws")
- : `ws://127.0.0.1:${remote.signalPort}/signal`;
- const signalOrigin = mode === "one-link"
- ? appInfo.publicOrigin!
- : `http://localhost:${appPort}`;
+ const signalUrl = new URL("/signal", appInfo.publicOrigin!).toString().replace(/^http/, "ws");
const remoteResult = await runRemotePeerGate(
remoteBinary,
remote,
signalUrl,
roomMatch[1],
viewerGrant,
- signalOrigin,
+ appInfo.publicOrigin!,
);
result.remoteViewerConnected = remoteResult.passed;
result.remoteViewerPackets = remoteResult.packets;
@@ -951,13 +922,16 @@ async function main(): Promise {
deadline,
),
(state) => state.connected && state.frames >= 30 &&
- state.width === 1920 && state.height === 1080,
+ state.width >= 320 && state.width <= 2560 && state.height >= 240 && state.height <= 1440,
40_000,
);
result.viewerConnected = viewerState.connected;
result.viewerFrames = viewerState.frames;
result.viewerWidth = viewerState.width;
result.viewerHeight = viewerState.height;
+ await evaluate(cdp, viewer,
+ `window.__piikGateSourceSize = ${JSON.stringify({ width: viewerState.width, height: viewerState.height })}`,
+ Date.now() + 5_000);
await assertVideoCodec(cdp, viewer, actualCodec);
if (mode === "one-link") {
result.codecPreserved = true;
@@ -988,9 +962,7 @@ async function main(): Promise {
(deadline) => evaluate(
cdp!,
viewer,
- `(() => { const video = document.querySelector('video'); return Boolean(
- video && video.videoWidth === ${width} && video.videoHeight === ${height}
- ); })()`,
+ `window.__piikGateFitsProfile(${width}, ${height})`,
deadline,
),
Boolean,
@@ -1016,7 +988,7 @@ async function main(): Promise {
`(() => {
const resolution = document.querySelector('button.lr-chip[aria-label="1440p"]');
const preference = document.querySelector('button[name="degradationPreference"][value="maintain-framerate"]');
- const sliders = [...document.querySelectorAll('.lr-slider input[type="range"]')];
+ const sliders = [...document.querySelectorAll('.lr-quality-rate input[type="range"]')];
return Boolean(
resolution && preference && !resolution.disabled && !preference.disabled &&
sliders.length === 2 && sliders.every((slider) => !slider.disabled)
@@ -1038,7 +1010,7 @@ async function main(): Promise {
cdp!,
viewer,
`(() => { const video = document.querySelector('video'); return Boolean(
- video && video.videoWidth === 2560 && video.videoHeight === 1440 &&
+ video && window.__piikGateFitsProfile(2560, 1440) &&
video.getVideoPlaybackQuality().totalVideoFrames >= ${viewerState.frames + 10}
); })()`,
deadline,
@@ -1049,9 +1021,77 @@ async function main(): Promise {
if (!result.liveQualityChanged) {
throw new Error("Native quality change did not reach the Viewer");
}
- result.viewerWidth = 2560;
- result.viewerHeight = 1440;
+ result.viewerWidth = viewerState.width;
+ result.viewerHeight = viewerState.height;
await assertVideoCodec(cdp, viewer, actualCodec);
+ stage = "native-4k-preset";
+ await clickHostControl(cdp, host, `document.querySelectorAll('button.lr-tile')[2]`);
+ await waitForValue(deadline => evaluate(cdp!, host,
+ `document.querySelector('input[name="maxFramerate"]')?.value === '60' &&
+ document.querySelector('.lr-sharing-panel')?.getAttribute('aria-busy') === 'false'`,
+ deadline), Boolean, 20_000);
+ stage = "native-4k-controls";
+ const framesBefore4K = await evaluate(cdp, viewer,
+ `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, Date.now() + 5_000);
+ await evaluate(cdp, host,
+ `document.querySelector('button.lr-chip[aria-label="4K"]')?.click()`, Date.now() + 5_000);
+ await waitForValue(deadline => evaluate(cdp!, host, `(() => {
+ const fps = document.querySelector('input[name="maxFramerate"]');
+ const width = document.querySelector('.lr-video-size input[name="width"]');
+ const height = document.querySelector('.lr-video-size input[name="height"]');
+ return fps && Number(fps.max) >= 60 && fps.validity.valid && fps.value === '60' &&
+ width?.value === '3840' && height?.value === '2160';
+ })()`, deadline), Boolean, 10_000);
+ stage = "native-4k-decode";
+ result.fourKQualityChanged = await waitForValue(deadline => evaluate(cdp!, viewer, `(() => {
+ const video = document.querySelector('video');
+ return Boolean(video && window.__piikGateFitsProfile(3840, 2160) &&
+ video.srcObject === window.__piikGateMedia &&
+ video.getVideoPlaybackQuality().totalVideoFrames >= ${framesBefore4K + 10});
+ })()`, deadline), Boolean, 20_000);
+ await assertVideoCodec(cdp, viewer, actualCodec);
+ stage = "native-custom-quality";
+ const framesBeforeCustom = await evaluate(cdp, viewer,
+ `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, Date.now() + 5_000);
+ await evaluate(cdp, host, `(() => {
+ const width = document.querySelector('.lr-video-size input[name="width"]');
+ const height = document.querySelector('.lr-video-size input[name="height"]');
+ width.focus(); width.value = '960';
+ height.focus(); height.value = '600'; height.blur();
+ })()`, Date.now() + 5_000);
+ result.customQualityChanged = await waitForValue(
+ deadline => evaluate(cdp!, viewer, `(() => {
+ const video = document.querySelector('video');
+ return Boolean(video && window.__piikGateFitsProfile(960, 600) &&
+ video.srcObject === window.__piikGateMedia &&
+ video.getVideoPlaybackQuality().totalVideoFrames >= ${framesBeforeCustom + 10});
+ })()`, deadline), Boolean, 20_000);
+ await assertVideoCodec(cdp, viewer, actualCodec);
+ stage = "native-rate-range";
+ for (const [fps, mbps] of [[1, 0.1], [60, 50]]) {
+ for (const [name, value] of [["maxFramerate", fps], ["maxBitrate", mbps]] as const) {
+ await evaluate(cdp, host, `(() => {
+ const input = document.querySelector('input[name="${name}"]');
+ if (!input || input.disabled) throw new Error('Rate control is unavailable');
+ input.focus(); input.value = '${value}';
+ if (!input.validity.valid) throw new Error('Rate control rejected the candidate range');
+ input.blur();
+ })()`, Date.now() + 5_000);
+ await waitForValue(deadline => evaluate(cdp!, host,
+ `document.querySelector('input[name="${name}"]')?.value === '${value}' &&
+ document.querySelector('.lr-sharing-panel')?.getAttribute('aria-busy') === 'false'`,
+ deadline), Boolean, 20_000);
+ }
+ const before = await evaluate(cdp, viewer,
+ `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, Date.now() + 5_000);
+ await waitForValue(deadline => evaluate(cdp!, viewer, `(() => {
+ const video = document.querySelector('video');
+ return Boolean(video && video.srcObject === window.__piikGateMedia &&
+ video.getVideoPlaybackQuality().totalVideoFrames >= ${before + 3});
+ })()`, deadline), Boolean, 20_000);
+ await assertVideoCodec(cdp, viewer, actualCodec);
+ result.rateQualityChanges += 1;
+ }
const framesBeforePreference = await evaluate(
cdp,
viewer,
@@ -1177,10 +1217,10 @@ async function main(): Promise {
deadline,
),
(value) => value.frames >= framesBeforeResume + 10 &&
- value.width === 854 && value.height === 480,
+ matchesCaptureSize(value.width, value.height, fittedCaptureSize(viewerState, 854, 480)),
20_000,
);
- result.pausedQualityChanged = resumed.width === 854 && resumed.height === 480;
+ result.pausedQualityChanged = matchesCaptureSize(resumed.width, resumed.height, fittedCaptureSize(viewerState, 854, 480));
result.mediaObjectPreserved = resumed.sameMedia;
result.viewerWidth = resumed.width;
result.viewerHeight = resumed.height;
@@ -1234,7 +1274,7 @@ async function main(): Promise {
`(() => {
const video = document.querySelector('video');
return Boolean(video && video.srcObject === window.__piikGateMedia &&
- video.videoWidth === ${width} && video.videoHeight === ${height} &&
+ window.__piikGateFitsProfile(${width}, ${height}) &&
video.getVideoPlaybackQuality().totalVideoFrames >= ${before + 10});
})()`, deadline,
), Boolean, 20_000);
@@ -1323,7 +1363,7 @@ async function main(): Promise {
const video = document.querySelector('video');
return Boolean(
video && video.srcObject === window.__piikGateMedia &&
- video.videoWidth === 854 && video.videoHeight === 480 &&
+ video.videoWidth > 0 && video.videoWidth <= 854 && video.videoHeight > 0 && video.videoHeight <= 480 &&
video.getVideoPlaybackQuality().totalVideoFrames >= ${framesBeforeSourceChange + 10}
);
})()`,
@@ -1431,7 +1471,7 @@ async function main(): Promise {
const video = document.querySelector('video');
return Boolean(video && video.srcObject &&
video.srcObject !== window.__piikGateMedia &&
- video.videoWidth === 854 && video.videoHeight === 480);
+ video.videoWidth > 0 && video.videoWidth <= 854 && video.videoHeight > 0 && video.videoHeight <= 480);
})()`,
deadline,
),
@@ -1467,9 +1507,6 @@ async function main(): Promise {
result.error = error instanceof Error ? error.message : String(error);
result.stage = stage;
} finally {
- if (remoteTunnel) {
- result.reverseSignalTunnelClosed = await stopChild(remoteTunnel);
- }
if (sourceChrome && sourceCdp) {
await closeSourceBrowser(sourceChrome, sourceCdp);
sourceChrome = null;
@@ -1505,20 +1542,21 @@ async function main(): Promise {
result.hostInvite &&
(remote
? result.remoteViewerConnected && result.remoteViewerPackets >= 30 &&
- result.remoteNatPath && result.remotePeerExited === true &&
- (mode !== "cross-nat" || result.reverseSignalTunnelClosed === true)
+ result.remoteNatPath && result.remotePeerExited === true
: mode === "one-link"
? result.publicViewerPage && result.publicViewerSignal && result.viewerConnected &&
- result.viewerFrames >= 30 && result.viewerWidth === 1920 && result.viewerHeight === 1080 && result.codecPreserved
+ result.viewerFrames >= 30 && result.viewerWidth > 0 && result.viewerWidth <= 2560 &&
+ result.viewerHeight > 0 && result.viewerHeight <= 1440 && result.codecPreserved
: result.viewerConnected && result.viewerFrames >= 30 &&
- result.qualityControlsEnabled && result.liveQualityChanged &&
+ result.qualityControlsEnabled && result.liveQualityChanged && result.fourKQualityChanged && result.customQualityChanged &&
+ result.rateQualityChanges === 2 &&
result.backgroundProfileRecovery !== false &&
result.sourceWindowRecovery !== false &&
result.livePresetChanges === 2 &&
result.codecPreserved &&
result.pausedQualityChanged && result.mediaObjectPreserved &&
result.nativeSourceChanged &&
- result.viewerWidth === 854 && result.viewerHeight === 480 &&
+ result.viewerWidth > 0 && result.viewerWidth <= 854 && result.viewerHeight > 0 && result.viewerHeight <= 480 &&
result.nativeQualityEvidence &&
(crashGate
? result.appCrashEndedShare === true
diff --git a/scripts/assemble-app.mjs b/scripts/assemble-app.mjs
index d7f337cc3..c42cc2707 100644
--- a/scripts/assemble-app.mjs
+++ b/scripts/assemble-app.mjs
@@ -40,7 +40,7 @@ function run(command, args, cwd, environment = process.env) {
});
if (result.error) throw result.error;
if (result.status !== 0) {
- fail(`${command} failed: ${(result.stderr || result.stdout).trim()}`);
+ fail(`${command} failed: ${[result.stdout, result.stderr].filter(Boolean).join("\n").trim()}`);
}
return result.stdout.trim();
}
diff --git a/scripts/browser-local-pool-page.js b/scripts/browser-local-pool-page.js
index 31d32218a..c5f66e524 100644
--- a/scripts/browser-local-pool-page.js
+++ b/scripts/browser-local-pool-page.js
@@ -1,7 +1,7 @@
// Actual HostPeer/BrowserEncodingPool comparison; synthetic source and local receivers only.
import { BrowserEncodingPool as ProbePool } from '../src/client/media/browser-encoding-pool';
import { HostPeer as ProbeHostPeer } from '../src/client/webrtc/host-peer';
-import { applyVideoCaptureProfile as probeApplyProfile } from '../src/client/media/quality';
+import { applyVideoCaptureProfile as probeApplyProfile, QUALITY_RESOLUTIONS } from '../src/client/media/quality';
import { browserDebugEnabled, exportBrowserDebug } from '../src/client/lib/debug';
const settings = new URLSearchParams(location.search);
@@ -15,10 +15,11 @@ const relay = settings.get('relay') === '1';
const shortPulse = settings.get('shortPulse') === '1';
const displaySource = settings.get('displaySource') === '1';
const quietStart = settings.get('quietStart') === '1';
-const selectedProfile = () => ({ resolution: high ? '1080p' : '480p', maxFramerate: 30,
- maxBitrate: high ? 5000000 : 1700000,
+const customSize = settings.has('width') ? { width: Number(settings.get('width')), height: Number(settings.get('height')) } : null;
+const selectedProfile = () => ({ resolution: customSize ?? QUALITY_RESOLUTIONS[high ? '1080p' : '480p'], maxFramerate: 30,
+ maxBitrate: customSize ? 12000000 : high ? 5000000 : 1700000,
degradationPreference: settings.get('preference') || (high ? 'maintain-resolution' : 'balanced'),
- screenAudioQuality: 'music' });
+ maxAudioBitrate: Number(settings.get('audioBitrate') || 128000) });
const productPool = mode === 'carrier' ? new ProbePool() : null;
const productPeers = [], peers = [], tracks = [], edges = [], monitors = [], audioMonitors = [];
const errors = [], sourcePulses = [], controls = [], visibility = [], productTrace = [], sourceFrames = [];
@@ -46,6 +47,8 @@ const poolState = () => productPool ? {
ready: g.ready, failed: g.failed, output: g.output }))
} : null;
window.probeProgress = () => ({ phase, sourceFrame, errors, pool: poolState(),
+ sourceSize: { width: rawSource?.getSettings().width, height: rawSource?.getSettings().height },
+ captured: sourceFrames.slice(-3), decoded: monitors.map(m => ({ role: m.role, last: m.samples.at(-1) })),
peers: peers.map(p => ({ connection: p.connectionState, ice: p.iceConnectionState })),
audio: audioMonitors.map(m => ({ role: m.role, attached: m.attached, pulses: m.pulses.length })) });
@@ -193,7 +196,8 @@ async function statistics() {
const result = {};
for (const edge of edges) {
const metrics = edge.peer.getSnapshot().metrics;
- result[edge.role] = { send: selectReport(await edge.send.getStats()),
+ result[edge.role] = { audioParameters: edge.peer.getSnapshot().audioSenderParameters,
+ send: selectReport(await edge.send.getStats()),
receive: selectReport(await edge.receive.getStats()),
details: Object.fromEntries(['frameWidth', 'frameHeight', 'framesPerSecond', 'bitrateKbps',
'qualityLimitationReason', 'nativeEdgeQualityState', 'intervalFramesSent', 'intervalFramesEncoded']
@@ -230,10 +234,10 @@ async function run() {
audioTrack = destination.stream.getAudioTracks()[0]; tracks.push(audioTrack);
}
const source = document.createElement('canvas');
- source.width = high ? 1920 : 640; source.height = high ? 1080 : 360;
+ source.width = customSize?.width ?? (high ? 1920 : 640); source.height = customSize?.height ?? (high ? 1080 : 360);
if (!nativeSource) document.body.prepend(source);
const ctx = source.getContext('2d');
- if (high) ctx.scale(3, 3);
+ ctx.scale(source.width / 640, source.height / 360);
if (displaySource) {
document.body.style.margin = '0';
source.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:10';
@@ -293,8 +297,10 @@ async function run() {
let activated = null;
for (;;) {
const members = [...productPool.members];
+ // Compatible allocations share an owner; distinct BWE budgets must
+ // remain independent even when this synthetic scene encodes cheaply.
const sharing = members.length === productPeers.length && members.every(m => m.current && m.carrier && !m.replacement) &&
- new Set(members.map(m => m.current)).size === 1;
+ new Set(members.map(m => m.current)).size === new Set(members.map(m => m.budget)).size;
if (sharing) activated ??= performance.now();
else activated = null;
if (activated !== null && monitors.every(m => {
@@ -334,16 +340,16 @@ async function run() {
phase = name; await wait(duration); snapshots.push(await snapshot());
if (name.startsWith('quiet-') || name.startsWith('paused-')) return;
for (const monitor of monitors) {
- const last = monitor.samples.at(-1), ceiling = { '480p': 480, '720p': 720, '1080p': 1080 }[appliedProfile.resolution];
+ const last = monitor.samples.at(-1), ceiling = appliedProfile.resolution;
if (last?.phase !== name || (!displaySource && (last.age === null || last.age >= 1000)) ||
- performance.now() - last.at >= 1000 || last.height > ceiling)
+ performance.now() - last.at >= 1000 || last.width > ceiling.width || last.height > ceiling.height)
throw Error(name + ': receiver did not deliver current frames within the requested ceiling');
}
};
- const p60 = { ...selectedProfile(), resolution: '720p', maxFramerate: 60, maxBitrate: 5000000 };
+ const p60 = { ...selectedProfile(), resolution: QUALITY_RESOLUTIONS['720p'], maxFramerate: 60, maxBitrate: 5000000 };
phase = 'profile-720p60'; await apply(p60); await observe(phase);
producing = false; phase = 'quiet-profile-480p30';
- await apply({ ...p60, resolution: '480p', maxFramerate: 30, maxBitrate: 3000000 });
+ await apply({ ...p60, resolution: QUALITY_RESOLUTIONS['480p'], maxFramerate: 30, maxBitrate: 3000000 });
await observe(phase, 2500);
producing = true; await observe('resumed-480p30');
rawSource.enabled = false; productPeers.forEach(peer => peer.setPaused(true));
diff --git a/scripts/browser-local-pool-probe.ts b/scripts/browser-local-pool-probe.ts
index 77afc1358..902d8cf34 100644
--- a/scripts/browser-local-pool-probe.ts
+++ b/scripts/browser-local-pool-probe.ts
@@ -6,15 +6,18 @@ import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { CdpConnection, cleanupRun, createPage, evaluate, launchChrome, reservePort, waitForSample, waitForVersion } from './browser-gate-harness';
import { createPoolShaper } from './browser-pool-shaper.mjs';
+import { parseVideoGateSize } from './media-gate-geometry';
const mode = process.argv[2] || 'carrier', args = process.argv.slice(3);
const flags = new Set(['--product', '--1080', '--h264', '--single', '--av', '--late', '--background',
'--native-source', '--lifecycle', '--network', '--auto', '--relay', '--debug', '--short-pulse', '--display', '--quiet-start']);
if (!['ordinary', 'carrier'].includes(mode)) throw Error('Expected ordinary|carrier');
-for (const arg of args) if (!flags.has(arg) && !arg.startsWith('--rate=') && !arg.startsWith('--preference='))
+for (const arg of args) if (!flags.has(arg) && !arg.startsWith('--rate=') && !arg.startsWith('--preference=') && !arg.startsWith('--audio-bitrate=') && !arg.startsWith('--size='))
throw Error('Unsupported option: ' + arg);
const codec = args.includes('--h264') ? 'H264' : 'VP8', network = args.includes('--network');
const automatic = args.includes('--auto'), high = args.includes('--1080'), av = args.includes('--av');
+const sizeArgument = args.find(arg => arg.startsWith('--size='));
+const size = sizeArgument ? parseVideoGateSize(sizeArgument.slice('--size='.length)) : undefined;
const single = args.includes('--single'), late = args.includes('--late'), background = args.includes('--background');
const displaySource = args.includes('--display');
const quietStart = args.includes('--quiet-start');
@@ -22,7 +25,9 @@ const nativeSource = background || args.includes('--native-source'), lifecycle =
const relay = args.includes('--relay');
const shortPulse = args.includes('--short-pulse');
const rate = Number(args.find(arg => arg.startsWith('--rate='))?.split('=')[1] || 120000);
-const preference = args.find(arg => arg.startsWith('--preference='))?.split('=')[1] || (high ? 'maintain-resolution' : 'balanced');
+const audioBitrate = Number(args.find(arg => arg.startsWith('--audio-bitrate='))?.split('=')[1] || 128000);
+if (!Number.isInteger(audioBitrate) || audioBitrate < 6000 || audioBitrate > 320000) throw Error('Audio bitrate must be 6000..320000 bps');
+const preference = args.find(arg => arg.startsWith('--preference='))?.split('=')[1] || (high || size ? 'maintain-resolution' : 'balanced');
if (!['balanced', 'maintain-resolution', 'maintain-framerate'].includes(preference)) throw Error('Unsupported degradation preference');
if (!Number.isInteger(rate) || rate <= 0) throw Error('Rate must be a positive integer in bps');
if (lifecycle && (nativeSource || network || relay)) throw Error('Lifecycle uses direct canvas/display capture without network shaping');
@@ -74,7 +79,7 @@ const port = (server.address() as { port: number }).port, debugPort = await rese
const profile = await mkdtemp(join(tmpdir(), 'piik-client-media-'));
const chrome = await launchChrome(process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe', debugPort, profile,
['--headless=new', '--no-first-run', '--autoplay-policy=no-user-gesture-required',
- ...(displaySource ? ['--window-size=1920,1200', '--force-device-scale-factor=1',
+ ...(displaySource ? [`--window-size=${size?.width ?? 1920},${size?.height ?? 1200}`, '--force-device-scale-factor=1',
'--auto-accept-this-tab-capture', '--auto-select-tab-capture-source-by-title=Browser local pool'] : []),
...(background ? [] : ['--disable-background-timer-throttling', '--disable-renderer-backgrounding']),
...(nativeSource ? ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] : [])]);
@@ -85,7 +90,10 @@ try {
const version = await waitForVersion(debugPort, chrome);
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5000);
page = await createPage(cdp, 'about:blank');
- const query = new URLSearchParams({ mode, codec, rate: String(rate), preference });
+ const query = new URLSearchParams({ mode, codec, rate: String(rate), preference, audioBitrate: String(audioBitrate) });
+ if (size) {
+ query.set('width', String(size.width)); query.set('height', String(size.height));
+ }
if (process.argv.includes('--debug')) query.set('debug', '1');
for (const [key, value] of Object.entries({ network, automatic, high, av, single, late, background, nativeSource, displaySource, quietStart, lifecycle, relay, shortPulse }))
query.set(key, value ? '1' : '0');
diff --git a/scripts/check-go.mjs b/scripts/check-go.mjs
index bb709206a..2d39b6570 100644
--- a/scripts/check-go.mjs
+++ b/scripts/check-go.mjs
@@ -38,7 +38,7 @@ function run(command, args, options = {}) {
if (result.error) throw result.error;
if (result.status !== 0) {
const detail = options.capture
- ? (result.stderr || result.stdout).trim()
+ ? [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
: "";
throw new Error(`${basename(command)} failed${detail ? `: ${detail}` : ""}`);
}
@@ -193,6 +193,7 @@ function checkPlatformCapture() {
throw new Error("Native capture probe returned an invalid contract");
}
if (process.platform === "darwin") {
+ run(executable, ["--geometry-self-test"]);
if (probe.adapters.some(adapter => adapter.hardwareH264.length > 0)) {
run(executable, ["--self-test"]);
} else {
@@ -220,6 +221,8 @@ if (mode === "--race") {
buildWebAssets();
runGoTests(process.env.PIIK_GO?.trim() || "go", [
"./internal/app/portmapping", "./internal/app/mediaedge",
+ "./internal/app/nativecontrol", "./internal/app/loopback",
+ "./internal/server/app",
"github.com/netbirdio/go-nat/...", "github.com/jackpal/go-nat-pmp",
], ["-race", "-count=1", "-timeout=2m"]);
} else {
diff --git a/scripts/check-website-film.js b/scripts/check-website-film.js
index 91fdc1af8..a083f8fc3 100644
--- a/scripts/check-website-film.js
+++ b/scripts/check-website-film.js
@@ -1,4 +1,4 @@
-// Run in a freshly opened local /film/ page using agent-browser eval --stdin.
+// Bundle this check, then run it in a fresh local /film/ page with agent-browser.
// This exercises the real document/module; it does not mock its scene renderer.
// First click #language with agent-browser to grant native audio user activation.
(async () => {
@@ -63,7 +63,7 @@
seek(time);
assert(scene().includes(`scene-${name}`), `The product sequence must show ${name}`);
}
- const {BEAT,BAR,DOWNLOAD_AT,DURATION} = await import('./score.js');
+ const {BEAT,BAR,DOWNLOAD_AT,DURATION} = await import('../site/film/score.js');
for (const [bar, feature] of [[19.5, 'free'], [20.5, 'p2p'], [21.5, 'encode'], [23, 'devices']]) {
seek(bar * BAR);
const visible = [...el('scene-features').children].filter(node => node.style.display !== 'none');
@@ -315,6 +315,9 @@
await at('website',0,lang);
const downloadedAgain = await at('website',4.5*BEAT,lang);
assert(Math.hypot(downloadedAgain.x-downloaded.x, downloadedAgain.y-downloaded.y)<.01, 'The website scroll and click must survive reverse seeking');
+ await at('launch',0,lang);
+ assert(doc.querySelector('[role="radio"][aria-checked="true"]').getAttribute('aria-label') ===
+ (lang === 'en' ? 'Public invite' : '公网邀请'), 'The launcher must start with Public invite selected');
await at('launch',5*BEAT,lang);
assert(doc.querySelector('.lr-room') && !doc.querySelector('.lr-room-chip'), 'Opening Piik must show the ready page before a room has been created');
await at('share',3*BEAT,lang);
@@ -338,12 +341,12 @@
'The viewer wide shot must include current playback, room interactions and the room code');
for (const [scene,beat,selector] of [
['desktop',2.5,'#desktop-app'],
- ['launch',1.5,'[role="radio"]'], ['launch',4,'button[type="submit"]'],
+ ['launch',1.5,'[role="radio"][aria-checked="true"]'], ['launch',4,'button[type="submit"]'],
['share',1.5,'.lr-entry-action button'], ['share',4,'.lr-source-option'],
['invite',1.5,'.lr-invite-url'], ['invite',4,'#chat-draft'], ['invite',6,'#chat-send'],
]) {
const p = await at(scene,beat*BEAT,lang);
- const element = doc.querySelectorAll(selector)[selector==='[role="radio"]'?1:0];
+ const element = doc.querySelector(selector);
const target = selector==='.lr-invite-url' ? element.closest('.lr-row').querySelector('button') : element;
const b = target.getBoundingClientRect();
assert(p.visible && p.x>=b.left && p.x<=b.right && p.y>=b.top && p.y<=b.bottom, 'Clicks must land on visible controls');
@@ -385,7 +388,7 @@
document.body.append(hero);
try {
await loaded;
- const {HERO_KINDS,HERO_CUT,heroFrame} = await import('../assets/activities.js');
+ const {HERO_KINDS,HERO_CUT,heroFrame} = await import('../site/assets/activities.js');
const doc = hero.contentDocument, win = hero.contentWindow;
assert(!doc.getElementById('montage-cheer'), 'The homepage and README hero must omit the film reaction caption');
const animations = doc.getAnimations();
diff --git a/scripts/embedded-sfu-gate.ts b/scripts/embedded-sfu-gate.ts
index b18e8bd7a..b1cb668b8 100644
--- a/scripts/embedded-sfu-gate.ts
+++ b/scripts/embedded-sfu-gate.ts
@@ -12,6 +12,7 @@ import {
import type { snapshot } from "./embedded-sfu-page";
import { readAppEndpoint } from "./app-gate-endpoint";
import { SOURCE_TITLE, sourceServer, startSourceBrowser, waitForCaptureWindow } from "./app-native-host-gate";
+import { fittedCaptureSize, parseVideoGateSize } from "./media-gate-geometry";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build", "embedded-media");
@@ -68,6 +69,9 @@ async function main(): Promise {
const sfuOnly = process.env.PIIK_EMBEDDED_SFU_ONLY === "true";
const displayArm = process.env.PIIK_EMBEDDED_SFU_DISPLAY === "true";
if (displayArm && nativeArm) throw new Error("Display and Native capture are separate arms");
+ const requestedSize = process.env.PIIK_EMBEDDED_SFU_VIDEO_SIZE?.trim();
+ const captureSize = parseVideoGateSize(requestedSize || "1280x720");
+ const sizeArgument = requestedSize ? JSON.stringify(captureSize) : "undefined";
const twoRooms = process.env.PIIK_EMBEDDED_SFU_TWO_ROOMS === "true";
if (twoRooms && !nativeArm) throw new Error("Two-room arm requires Native publication");
const codec = process.env.PIIK_EMBEDDED_SFU_CODEC?.trim() || "vp8";
@@ -94,17 +98,20 @@ async function main(): Promise {
const sourceProfile = nativeArm ? await mkdtemp(join(tmpdir(), "piik-client-media-")) : null;
const [sourcePort, sourceDebugPort] = nativeArm ? [await reservePort(), await reservePort()] : [0, 0];
const result = { passed: false, arm: `${nativeArm ? "native" : displayArm ? "display" : "browser"}-${codec}-simulcast${twoRooms ? "-two-rooms" : ""}`, stage: "start",
- sfuOnly, stereoSeparationDb: null as number[] | null,
+ sfuOnly, captureSize, stereoSeparationDb: null as number[] | null,
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, recovered: 0 },
+ audioUpdates: [] as Array<{ bitrate: number; frames: number; connectionRetained: boolean; energy: number }>,
+ videoRateUpdates: [] as Array<{ fps: number; bitrate: number; frames: number; connectionRetained: boolean }>,
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,
displayLifecycle: null as { hiddenFrames: number; hiddenElapsedMs: number; lowLayerDecoded: boolean; resumed: boolean; replaced: boolean } | null,
mediaDiagnostics: null as unknown,
- publicationRetired: false, subscriptionRecovered: false, nativeShareStopped: !nativeArm, udpReleased: false, cleanup: false, error: null as string | null };
+ sourceDrawing: null as { draws: number; elapsedMs: number; visibility: string } | null,
+ publicationRetired: false, subscriptionRecovered: false, pageReloadRecovered: 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;
@@ -118,6 +125,7 @@ async function main(): Promise {
let nativePort = 0;
let sourceChrome: ChildProcessWithoutNullStreams | null = null;
let sourceCdp: CdpConnection | null = null;
+ let sourcePage: PageHandle | null = null;
let sourceHTTP: Awaited> | null = null;
const call = async (page: PageHandle, expression: string, timeout = 25_000): Promise => {
const value = await evaluate<{ value?: T; error?: string }>(cdp!, page,
@@ -162,10 +170,10 @@ async function main(): Promise {
if (request.url === "/embedded-sfu-source") {
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(`Piik SFU captured source
- `);
+ function draw(){ctx.fillStyle='#167d8d';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#f2d748';
+ ctx.fillRect((n++*17)%canvas.width,60,100,100);requestAnimationFrame(draw)}draw();`);
return;
}
if (request.url !== "/embedded-sfu-gate") return next();
@@ -195,6 +203,7 @@ async function main(): Promise {
if (!target) throw new Error("Gate source page is unavailable");
const attached = await sourceCdp.call<{ sessionId: string }>("Target.attachToTarget",
{ targetId: target.targetId, flatten: true }, undefined, Date.now() + 5_000);
+ sourcePage = attached;
const audioState = await evaluate(sourceCdp, attached, `(async () => {
const audio = new AudioContext(), tone = audio.createOscillator(), gain = audio.createGain();
gain.gain.value = 0.01; tone.connect(gain).connect(audio.destination); tone.start();
@@ -224,15 +233,15 @@ async function main(): Promise {
}
if (displayArm) {
const sourcePage = await createPage(cdp, `${origin}/embedded-sfu-source`);
- await cdp.call("Emulation.setDeviceMetricsOverride", { width: 1280, height: 720,
+ await cdp.call("Emulation.setDeviceMetricsOverride", { ...captureSize,
deviceScaleFactor: 1, mobile: false }, sourcePage.sessionId, Date.now() + 3_000);
}
host = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
viewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
result.stage = "authenticate";
const room = await call(host, nativeArm
- ? `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: nativePort })}, ${JSON.stringify(codec)})`
- : `gate.startHost(undefined, ${JSON.stringify(codec)}, ${displayArm})`);
+ ? `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: nativePort })}, ${JSON.stringify(codec)}, false, ${sizeArgument})`
+ : `gate.startHost(undefined, ${JSON.stringify(codec)}, ${displayArm}, ${sizeArgument})`);
await until(host, (value) => value.authenticated);
await call(viewer, `gate.startViewer(${JSON.stringify(room)})`);
result.stage = "simulcast-first-frame";
@@ -242,12 +251,12 @@ async function main(): Promise {
secondHost = await createPage(cdp, `${origin}/embedded-sfu-gate`);
secondViewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
const secondRoom = await call(secondHost,
- `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: nativePort })}, ${JSON.stringify(codec)})`);
+ `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: nativePort })}, ${JSON.stringify(codec)}, false, ${sizeArgument})`);
if (secondRoom.roomId === room.roomId) throw new Error("Native sessions did not create independent rooms");
await until(secondHost, (value) => value.authenticated);
await call(secondViewer, `gate.startViewer(${JSON.stringify(secondRoom)})`);
await until(secondViewer, (value) => value.committed && value.audioKbps > 0);
- await call(secondViewer, "gate.waitForFrames(1280, 720)");
+ await call(secondViewer, `gate.waitForFrames(${captureSize.width}, ${captureSize.height}, 15, true)`);
await waitForSample(() => call(secondViewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.secondRoom!.audioBefore = energy; return energy > 0; }, 5_000);
const second = await call(secondHost, "gate.snapshot()");
@@ -256,38 +265,78 @@ async function main(): Promise {
result.stage = "simulcast-first-frame";
await cdp.call("Page.bringToFront", {}, viewer.sessionId, Date.now() + 3_000);
result.host = await call(host, "gate.snapshot()");
- result.high = await call(viewer, nativeArm ? "gate.waitForFrames(1280, 720, 300)" : "gate.waitForFrames(1280, 720)", nativeArm ? 32_000 : 25_000);
+ const sourceSample = async () => sourceCdp && sourcePage
+ ? await evaluate<{ draws: number; at: number; visibility: string }>(sourceCdp, sourcePage,
+ "({ draws: n, at: performance.now(), visibility: document.visibilityState })", Date.now() + 3_000)
+ : null;
+ const sourceBefore = await sourceSample();
+ try {
+ result.high = await call(viewer, `gate.waitForFrames(${captureSize.width}, ${captureSize.height}, ${nativeArm ? "300, true" : "15"})`, nativeArm ? 32_000 : 25_000);
+ } finally {
+ const sourceAfter = await sourceSample().catch(() => null);
+ if (sourceBefore && sourceAfter) result.sourceDrawing = {
+ draws: sourceAfter.draws - sourceBefore.draws,
+ elapsedMs: sourceAfter.at - sourceBefore.at,
+ visibility: sourceAfter.visibility,
+ };
+ }
result.stage = "decoded-audio-energy";
await waitForSample(() => call(viewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.audioEnergy.high = energy; return energy > 0; }, 5_000);
if (!nativeArm) result.stereoSeparationDb = await call(viewer, "gate.audioSeparation()", 5_000);
result.host = await call(host, "gate.snapshot()");
+ result.stage = "numeric-audio-update";
+ const audioConnection = (await call(viewer, "gate.snapshot()")).connectionId;
+ let previousEnergy = result.audioEnergy.high;
+ for (const bitrate of [320_000, 96_000]) {
+ if (!await call(host, `gate.setAudioBitrate(${bitrate})`)) throw new Error("Numeric audio update failed");
+ const frames = await call<{ frames: number }>(viewer, `gate.waitForFrames(${result.high!.width}, ${result.high!.height})`);
+ const after = await call(viewer, "gate.snapshot()");
+ const energy = await call(viewer, "gate.audioEnergy()", 3_000);
+ const retained = after.connectionId === audioConnection && after.committed;
+ result.audioUpdates.push({ bitrate, frames: frames.frames, connectionRetained: retained, energy });
+ if (!retained || energy <= previousEnergy) throw new Error("Audio update interrupted the current media connection");
+ previousEnergy = energy;
+ }
+ result.stage = "numeric-video-rate-update";
+ const videoRates = [[1, 100_000], requestedSize ? [60, 50_000_000] : [15, 2_000_000]];
+ for (const [fps, bitrate] of videoRates) {
+ if (!await call(host, `gate.setVideoRates(${fps}, ${bitrate})`)) throw new Error("Numeric video rate update failed");
+ const frames = await call<{ frames: number }>(viewer,
+ `gate.waitForFrames(${result.high!.width}, ${result.high!.height}, 3)`);
+ const after = await call(viewer, "gate.snapshot()");
+ const retained = after.connectionId === audioConnection && after.committed;
+ result.videoRateUpdates.push({ fps, bitrate, frames: frames.frames, connectionRetained: retained });
+ if (!retained) throw new Error("Video rate update replaced the current media connection");
+ }
if (displayArm) {
result.stage = "display-background";
if (result.host!.capture?.displaySurface !== "browser" || result.host!.visibility !== "hidden") {
throw new Error("Gate must capture a separate real tab with the Host hidden");
}
const hiddenStart = Date.now();
- const hidden = await call<{ frames: number }>(viewer, "gate.waitForFrames(1280, 720, 150)", 32_000);
+ const fullSize = `${result.high!.width}, ${result.high!.height}`;
+ const hidden = await call<{ frames: number }>(viewer, `gate.waitForFrames(${fullSize}, 150)`, 32_000);
result.displayLifecycle = { hiddenFrames: hidden.frames, hiddenElapsedMs: Date.now() - hiddenStart,
lowLayerDecoded: false, resumed: false, replaced: false };
result.mediaDiagnostics = await Promise.all([host, viewer].map(page =>
evaluate(cdp!, page, "window.__piikGateMediaStats()", Date.now() + 3_000)));
result.stage = "display-layer-demand";
await call(host, "gate.demandLayers(1)");
- await call(viewer, "gate.waitForFrames(640, 360)");
+ const half = fittedCaptureSize(result.high!, result.high!.width / 2, result.high!.height / 2);
+ await call(viewer, `gate.waitForFrames(${half.width}, ${half.height}, 15, false, 2)`);
result.displayLifecycle.lowLayerDecoded = true;
await call(host, "gate.demandLayers(2)");
- await call(viewer, "gate.waitForFrames(1280, 720)");
+ await call(viewer, `gate.waitForFrames(${fullSize})`);
result.stage = "display-pause-resume";
await call(host, "gate.pause(true)");
await new Promise(resolve => setTimeout(resolve, 500));
await call(host, "gate.pause(false)");
- await call(viewer, "gate.waitForFrames(1280, 720)");
+ await call(viewer, `gate.waitForFrames(${fullSize})`);
result.displayLifecycle.resumed = true;
result.stage = "display-source-replacement";
if (!await call(host, "gate.replaceSource()")) throw new Error("Display source replacement failed");
- await call(viewer, "gate.waitForFrames(1280, 720)");
+ await call(viewer, `gate.waitForFrames(${fullSize})`);
result.displayLifecycle.replaced = true;
}
result.stage = "live-profile";
@@ -295,9 +344,18 @@ async function main(): Promise {
result.host = await call(host, "gate.snapshot()");
throw new Error("Live profile update failed");
}
- result.low = await call(viewer, "gate.waitForFrames(854, 480)");
+ const lowSize = fittedCaptureSize(result.high!, 854, 480);
+ result.low = await call(viewer, `gate.waitForFrames(${lowSize.width}, ${lowSize.height}, 15, false, 2)`);
await waitForSample(() => call(viewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.audioEnergy.low = energy; return energy > result.audioEnergy.high; }, 5_000);
+ result.stage = "viewer-media-reload";
+ const retainedSubscription = (await call(viewer, "gate.snapshot()")).connectionId;
+ // A reload loses local media before reporting failure, while room authority
+ // still retains the old committed edge inside the reconnect grace period.
+ await call(viewer, `gate.reconnectViewer(${JSON.stringify(room)}, true)`);
+ await until(viewer, value => value.committed && value.connectionId !== retainedSubscription && value.audioKbps > 0);
+ await call(viewer, `gate.waitForFrames(${result.low!.width}, ${result.low!.height})`);
+ result.pageReloadRecovered = true;
result.stage = "committed-subscription-recovery";
const previousSubscription = await call(viewer, "gate.failSubscription()");
// Terminal failure consumes this exact opportunity. A new authenticated
@@ -305,7 +363,7 @@ async function main(): Promise {
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 call(viewer, `gate.waitForFrames(${result.low!.width}, ${result.low!.height})`);
await waitForSample(() => call(viewer!, "gate.audioEnergy()", 3_000),
energy => { result.audioEnergy.recovered = energy; return energy > 0; }, 5_000);
result.viewer = await call(viewer, "gate.snapshot()");
@@ -321,7 +379,7 @@ async function main(): Promise {
if (!(await call(host, "gate.snapshot()")).nativeShareStopped) throw new Error("First Native capture did not stop");
result.secondRoom!.audioBefore = await call(secondViewer, "gate.audioEnergy()");
await cdp.call("Page.bringToFront", {}, secondViewer.sessionId, Date.now() + 3_000);
- const continued = await call<{ frames: number }>(secondViewer, "gate.waitForFrames(1280, 720, 30)");
+ const continued = await call<{ frames: number }>(secondViewer, `gate.waitForFrames(${result.high!.width}, ${result.high!.height}, 30)`);
result.secondRoom!.framesAfterFirstStopped = continued.frames;
await waitForSample(() => call(secondViewer!, "gate.audioEnergy()", 3_000),
(energy) => { result.secondRoom!.audioAfter = energy; return energy > result.secondRoom!.audioBefore; }, 5_000);
@@ -337,6 +395,7 @@ async function main(): Promise {
if (cdp) result.mediaDiagnostics = await Promise.all([host, viewer].map(page => page ?
evaluate(cdp!, page, "window.__piikGateMediaStats?.()", Date.now() + 3_000).catch(() => null) : null));
if (viewer && cdp) result.viewer = await call(viewer, "gate.snapshot()", 3_000).catch(() => null);
+ if (host && cdp) result.host = await call(host, "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 (serverError) result.error += `; server: ${serverError}`;
@@ -366,7 +425,8 @@ async function main(): Promise {
(nativeArm || result.stereoSeparationDb?.every(db => db > 20) === true) &&
result.viewer.audioKbps > 0 && result.audioEnergy.high > 0 && result.audioEnergy.low > result.audioEnergy.high &&
result.high !== null && result.low !== null && result.audioEnergy.recovered > 0 &&
- result.publicationRetired && result.subscriptionRecovered && result.cleanup && result.nativeShareStopped &&
+ result.videoRateUpdates.length === 2 && result.videoRateUpdates.every(update => update.connectionRetained) &&
+ result.publicationRetired && result.subscriptionRecovered && result.pageReloadRecovered && result.cleanup && result.nativeShareStopped &&
(!displayArm || result.displayLifecycle?.hiddenFrames === 150 && result.displayLifecycle.lowLayerDecoded &&
result.displayLifecycle.resumed && result.displayLifecycle.replaced) &&
(!result.secondRoom || result.secondRoom.connected && result.secondRoom.framesAfterFirstStopped >= 30 &&
diff --git a/scripts/embedded-sfu-page.ts b/scripts/embedded-sfu-page.ts
index 207de8ebb..123cdeb56 100644
--- a/scripts/embedded-sfu-page.ts
+++ b/scripts/embedded-sfu-page.ts
@@ -16,12 +16,12 @@ import { NativeSfuPublisher } from "../src/client/native/native-sfu-publisher";
import { defaultNativeCapturePath } from "../src/client/native/capture-selection";
import { HostSfuRoute, type HostPublisherTransport } from "../src/client/media/host-sfu-route";
import { ViewerSfuRoute } from "../src/client/media/viewer-sfu-route";
+import { fittedCaptureSize } from "./media-gate-geometry";
const high: QualitySettings = {
- resolution: "720p", maxFramerate: 15, maxBitrate: 2_000_000,
- degradationPreference: "maintain-resolution", screenAudioQuality: "saver",
+ resolution: { width: 1280, height: 720 }, maxFramerate: 15, maxBitrate: 2_000_000,
+ degradationPreference: "maintain-resolution", maxAudioBitrate: 64000,
};
-const low: QualitySettings = { ...high, resolution: "480p" };
const shareGeneration = crypto.randomUUID();
const clientId = crypto.randomUUID();
let socket: WebSocket | null = null;
@@ -157,7 +157,9 @@ async function accept(message: ServerMessage, host: boolean): Promise {
}
}
if (message.type === "sfu-config") {
- configuration = message;
+ // A late refresh can describe the old edge after a newer prepare. The route
+ // owner rejects it; the fixture's reported identity must not move backward.
+ if (!configuration || message.revision >= configuration.revision) configuration = message;
if (host) {
await hostRoute?.acceptConfig(message);
published = publisher !== null;
@@ -173,6 +175,7 @@ async function connect(room: CreateRoomResponse, host: boolean): Promise {
socket = new WebSocket(new URL("/signal", location.href).href.replace(/^http/, "ws"));
const connection = socket;
socket.addEventListener("message", ({ data }) => {
+ if (socket !== connection) return;
messageTail = messageTail.then(() => accept(decodeServerMessage(String(data)), host)).catch(failed);
});
socket.addEventListener("error", () => failed("Room WebSocket failed"));
@@ -190,12 +193,13 @@ async function connect(room: CreateRoomResponse, host: boolean): Promise {
roomId: room.roomId, clientId } as const;
send(host
? { ...common, role: "host", token: room.hostToken, shareGeneration,
- qualitySettings: high, routePolicy: { peerOnly: false, topologyOptimization: false, natPrediction: false } }
+ qualitySettings: profile, routePolicy: { peerOnly: false, topologyOptimization: false, natPrediction: false } }
: { ...common, role: "viewer", viewerGrant: new URLSearchParams(new URL(room.inviteUrl).hash.slice(1)).get("v")! });
}
-export async function startHost(nativeSource?: { title: string; port: number }, selectedCodec: "vp8" | "h264" = "vp8", display = false): Promise {
+export async function startHost(nativeSource?: { title: string; port: number }, selectedCodec: "vp8" | "h264" = "vp8", display = false, size?: QualitySettings["resolution"]): Promise {
codec = selectedCodec;
+ profile = size ? { ...high, resolution: size, maxFramerate: 60, maxBitrate: 50_000_000 } : high;
const response = await fetch("/api/rooms", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeEntryPolicy: "private" }),
@@ -212,7 +216,7 @@ export async function startHost(nativeSource?: { title: string; port: number },
if (!stopping && event.type === "share-ended" && event.shareId === shareGeneration) failed("Native capture ended");
});
const started = await native.startShare({ shareId: shareGeneration, source: target,
- codec, audio: true, profile: high, edgeCapacity: 2, ...path });
+ codec, audio: true, profile, 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");
@@ -220,8 +224,8 @@ export async function startHost(nativeSource?: { title: string; port: number },
return room;
}
canvas = document.createElement("canvas");
- canvas.width = 1280;
- canvas.height = 720;
+ canvas.width = profile.resolution.width;
+ canvas.height = profile.resolution.height;
canvas.style.cssText = "max-width:100%;width:640px";
document.body.append(canvas);
const context = canvas.getContext("2d")!;
@@ -234,10 +238,10 @@ export async function startHost(nativeSource?: { title: string; port: number },
context.fillStyle = "#ffffff";
context.font = "48px sans-serif";
context.fillText(String(frame), 40, 240);
- }, 1000 / 15);
+ }, 1000 / profile.maxFramerate);
source = display ? await navigator.mediaDevices.getDisplayMedia({
- video: { width: 1280, height: 720, frameRate: 15 }, audio: false,
- }) : canvas.captureStream(15);
+ video: { ...profile.resolution, frameRate: profile.maxFramerate }, audio: false,
+ }) : canvas.captureStream(profile.maxFramerate);
audio = new AudioContext();
const gain = audio.createGain();
gain.gain.value = 0.05;
@@ -295,16 +299,25 @@ export async function startViewer(room: CreateRoomResponse): Promise {
await connect(room, false);
}
-export async function reconnectViewer(room: CreateRoomResponse): Promise {
+export async function reconnectViewer(room: CreateRoomResponse, discardMedia = false): Promise {
const previous = socket;
socket = null;
previous?.close();
+ if (discardMedia) {
+ await messageTail;
+ await viewerRoute?.disconnect();
+ viewerRoute = null;
+ video?.remove();
+ video = null;
+ decoded = committed = false;
+ }
authenticated = false;
await connect(room, false);
}
export function snapshot() {
return { authenticated, published, simulcast, decoded, committed, peerFailures,
+ maxAudioBitrate: profile.maxAudioBitrate,
visibility: document.visibilityState, capture: source?.getVideoTracks()[0]?.getSettings(),
routeEvents, routeStatus,
connectionId: configuration?.connectionId,
@@ -329,7 +342,23 @@ export async function audioEnergy(): Promise {
return await probe();
}
-export async function waitForFrames(width: number, height: number, minimumFrames = 15) {
+async function applyProfile(next: QualitySettings): Promise {
+ if (native) await native.updateShare(shareGeneration, next);
+ if (!await hostRoute!.updateProfile(next)) return false;
+ profile = next;
+ send({ type: "set-quality-settings", qualitySettings: next });
+ return true;
+}
+
+export async function setAudioBitrate(maxAudioBitrate: number): Promise {
+ return await applyProfile({ ...profile, maxAudioBitrate });
+}
+
+export async function setVideoRates(maxFramerate: number, maxBitrate: number): Promise {
+ return await applyProfile({ ...profile, maxFramerate, maxBitrate });
+}
+
+export async function waitForFrames(width: number, height: number, minimumFrames = 15, withinCeiling = false, tolerance = 0) {
if (!video) throw new Error("Viewer video is unavailable");
const target = video;
return await new Promise<{ frames: number; width: number; height: number }>((resolve, reject) => {
@@ -337,11 +366,14 @@ export async function waitForFrames(width: number, height: number, minimumFrames
let callback = 0;
const timer = setTimeout(() => {
target.cancelVideoFrameCallback(callback);
- reject(new Error(`Viewer did not decode ${width}x${height}; current ${target.videoWidth}x${target.videoHeight}; ${frames} frame callbacks; ${document.visibilityState}; ${error ?? "no signaling error"}`));
+ reject(new Error(`Viewer did not reach ${minimumFrames} decoded frame callbacks at ${width}x${height}; current ${target.videoWidth}x${target.videoHeight}; ${frames} matching callbacks; ${document.visibilityState}; ${error ?? "no signaling error"}`));
}, minimumFrames > 15 ? 30_000 : 20_000);
const next = () => {
callback = target.requestVideoFrameCallback(() => {
- frames = target.videoWidth === width && target.videoHeight === height ? frames + 1 : 0;
+ const matches = withinCeiling
+ ? target.videoWidth >= 320 && target.videoHeight >= 240 && target.videoWidth <= width && target.videoHeight <= height
+ : Math.abs(target.videoWidth - width) <= tolerance && Math.abs(target.videoHeight - height) <= tolerance;
+ frames = matches ? frames + 1 : 0;
if (frames < minimumFrames) return next();
clearTimeout(timer);
resolve({ frames, width: target.videoWidth, height: target.videoHeight });
@@ -353,11 +385,13 @@ export async function waitForFrames(width: number, height: number, minimumFrames
export async function lowerProfile(): Promise {
if (!publisher) throw new Error("Publisher is unavailable");
+ const low = { ...profile, resolution: { width: 854, height: 480 } };
if (native) await native.updateShare(shareGeneration, low);
else {
if (!canvas) throw new Error("Browser source is unavailable");
- canvas.width = 854;
- canvas.height = 480;
+ const size = fittedCaptureSize(canvas, low.resolution.width, low.resolution.height);
+ canvas.width = size.width;
+ canvas.height = size.height;
}
profile = low;
send({ type: "set-quality-settings", qualitySettings: low });
diff --git a/scripts/encoded-group-decode.mjs b/scripts/encoded-group-decode.mjs
index 76cfcfa5b..68804635f 100644
--- a/scripts/encoded-group-decode.mjs
+++ b/scripts/encoded-group-decode.mjs
@@ -6,7 +6,7 @@ import { resolve } from "node:path";
const customInput = process.env.PIIK_ENCODED_OUTPUT;
const codec = process.env.PIIK_ENCODED_CODEC ?? "vp8";
-assert.ok(["vp8", "avc1.42c033"].includes(codec), "Expected VP8 or constrained-baseline H264");
+assert.ok(["vp8", "avc1.42c033", "avc1.42c034"].includes(codec), "Expected VP8 or constrained-baseline H264");
const inputPath = customInput ? resolve(customInput) : new URL("../build/embedded-media/encoded-group.received.json", import.meta.url);
const input = await readFile(inputPath);
const streams = JSON.parse(input);
diff --git a/scripts/media-gate-geometry.ts b/scripts/media-gate-geometry.ts
new file mode 100644
index 000000000..77b04a216
--- /dev/null
+++ b/scripts/media-gate-geometry.ts
@@ -0,0 +1,22 @@
+import { captureFramerateCeiling } from "../src/client/media/quality";
+
+export function parseVideoGateSize(value: string) {
+ const match = /^(\d+)x(\d+)$/.exec(value);
+ const size = { width: Number(match?.[1]), height: Number(match?.[2]) };
+ if (captureFramerateCeiling(size) < 30) {
+ throw new Error("Video size must be WIDTHxHEIGHT within current 30 fps capture limits");
+ }
+ return size;
+}
+
+// Gate expectations derive from the decoded source instead of assuming a 16:9
+// canvas. Two pixels allow independent NV12 rounding at successive ceilings.
+export function fittedCaptureSize(source: { width: number; height: number }, width: number, height: number) {
+ const scale = Math.min(1, width / source.width, height / source.height);
+ return { width: Math.max(2, Math.floor(source.width * scale / 2) * 2),
+ height: Math.max(2, Math.floor(source.height * scale / 2) * 2) };
+}
+
+export function matchesCaptureSize(width: number, height: number, expected: { width: number; height: number }) {
+ return width > 0 && height > 0 && Math.abs(width - expected.width) <= 2 && Math.abs(height - expected.height) <= 2;
+}
diff --git a/scripts/mirror-release.mjs b/scripts/mirror-release.mjs
index e881b73ae..bb569c950 100644
--- a/scripts/mirror-release.mjs
+++ b/scripts/mirror-release.mjs
@@ -15,6 +15,15 @@ const api = `https://gitee.com/api/v5/repos/${repository}`;
const marker = ``;
const token = process.env.GITEE_TOKEN?.trim();
+function requireLatestSource() {
+ const latest = JSON.parse(execFileSync("gh", ["api", `repos/${repository}/releases/latest`],
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 }));
+ if (latest.tag_name !== version || latest.target_commitish !== revision ||
+ latest.draft !== false || latest.prerelease !== false) {
+ throw new Error("Only the latest GitHub release may create or complete a Gitee mirror");
+ }
+}
+
async function fetchResponse(url, options, timeout) {
const attempts = (options.method ?? "GET") === "GET" ? 3 : 1;
for (let attempt = 1; ; attempt++) {
@@ -88,6 +97,7 @@ if (option === "--dry-run") {
throw new Error("Existing mirror release belongs to a different or unknown source revision");
}
const body = `${source.body ?? ""}\n\nSource: https://github.com/${repository}/commit/${revision}\n\n${marker}\n`;
+ if (!release || release.prerelease) requireLatestSource();
if (!release) {
const repo = await request("");
if (!repo || repo.private || !repo.default_branch) throw new Error("A public initialized Gitee mirror is required");
@@ -111,6 +121,8 @@ if (option === "--dry-run") {
}
// Gitee has no draft API. Its preview flag fences incomplete uploads from checks.
if (release.prerelease) {
+ // Uploads may take minutes; a newer GitHub release must not be displaced.
+ requireLatestSource();
const completed = await request(`/releases/${release.id}`, "PATCH", {
tag_name: version, name: source.name || `Piik ${version}`, body, prerelease: false,
});
diff --git a/scripts/network-lab.ts b/scripts/network-lab.ts
new file mode 100644
index 000000000..5a0c9220e
--- /dev/null
+++ b/scripts/network-lab.ts
@@ -0,0 +1,384 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { once } from "node:events";
+import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { parseArgs } from "node:util";
+import { setTimeout as delay } from "node:timers/promises";
+import { cleanupRun, withDeadline } from "./browser-gate-harness";
+import { endpointWorker } from "./network-lab/endpoint";
+import { command, LabNetwork, SITE_IP, SITE_IPV6, SITE_ORIGIN } from "./network-lab/network";
+import { checkAppPath, checkCalibration, checkContinuity, checkFaultHit, checkMapping, checkQualityRecovery, hasPicture, selectScenarios, summarizeTrials, type Scenario, type TrialSummary } from "./network-lab/scenarios";
+import { echoWorker, type ProbeResult } from "./network-lab/udp";
+import type { MediaSample } from "./network-lab/page";
+import { checkMediaFeedback, checkMediaPath, readPacketFlows, type PacketFlow } from "./network-lab/packets";
+import { verifyLaboratory } from "./network-lab/verify";
+
+const ROOT = resolve(import.meta.dirname, ".."), ENTRY = resolve(import.meta.filename);
+const { values } = parseArgs({ options: { worker: { type: "string" }, suite: { type: "string", default: "smoke" },
+ cases: { type: "string" }, repeat: { type: "string", default: "1" }, seed: { type: "string", default: "1" },
+ prediction: { type: "string", default: "on" },
+ ipv6: { type: "boolean" }, list: { type: "boolean" }, verify: { type: "boolean" }, negative: { type: "string" } } });
+
+class Endpoint {
+ private id = 0;
+ private failure: Error | null = null;
+ private readonly pending = new Map void; reject: (error: Error) => void }>();
+ readonly process: ChildProcessWithoutNullStreams;
+ constructor(namespace: string, readonly profile: string, private readonly signal: AbortSignal) {
+ this.process = spawn("ip", ["netns", "exec", namespace, process.execPath, "--import", "tsx", ENTRY, "--worker", "endpoint"],
+ { cwd: ROOT, env: { ...labEnvironment(), PIIK_NETWORK_LAB_PROFILE: profile }, stdio: "pipe" });
+ this.process.stderr.on("data", data => process.stderr.write(data));
+ this.process.stdin.on("error", error => this.fail(error));
+ createInterface({ input: this.process.stdout }).on("line", line => {
+ try {
+ const reply = JSON.parse(line), pending = this.pending.get(reply.id);
+ if (!pending) return;
+ this.pending.delete(reply.id);
+ if (reply.error) pending.reject(new Error(reply.error)); else pending.resolve(reply.result);
+ } catch { this.fail(new Error("Invalid endpoint response")); }
+ });
+ this.process.on("error", error => this.fail(error));
+ this.process.on("exit", (code, signal) => this.fail(new Error(`Endpoint exited (${code ?? signal})`)));
+ }
+ private fail(error: Error): void { this.failure = error; for (const item of this.pending.values()) item.reject(error); this.pending.clear(); }
+ async call