feat: improve microphone, chat and platform support

This release adds microphone processing controls and local chat controls, expands platform packages, and synchronizes the website and guides. It also fixes native H.264 input/format handling, signaling recovery, fresh STUN discovery, and UDP ownership during overlapping connections.

This is **v1.8.0**. Browser/Server v23, Native control v9 and capture v7 remain unchanged, with the existing 1440p ceiling. Additional resolutions, capability negotiation and Magicsock are separate follow-up work.

## Validation

- Full local Web check: 79 files / 1,317 tests, TypeScript and production build.
- Full Go checks, platform builds and Windows native capture regressions/probe.
- Native H.264 Host lifecycle, Browser-to-App fanout and App-exit recovery, and Native H.264 through embedded SFU: all passed with owned process/port/profile cleanup.
- Website build, documentation and repository hygiene passed. Existing UI acceptance covers narrow screens, both languages/themes, architecture downloads and all 23 film checks; final packaging and Go race checks run on the merged revision in CI.
- Physical macOS/Linux capture, representative public networks and unmatched field reports retain their documented evidence limits. No claim that every blur/disconnection report is resolved.

## Release notes

### 中文

这是一次功能与兼容性更新:麦克风和聊天增加了实用的小选项,App 与 Server 支持更多设备,同时修复了原生分享和连接恢复中的若干问题。现有 1.x 协议保持不变。

#### 新增与改进

- 浏览器麦克风新增「降噪与回声消除」开关,默认开启回声消除、降噪和自动增益;分享音乐或使用虚拟音频输入时,可按需关闭。
- 可调整弹幕字号和透明度,并将当前保留的聊天记录导出为 TXT。当前会话最多保留 1,000 条文字消息,刷新页面后清空;需要保存时请先导出。
- 新增 Intel Mac、Linux ARM64 App,以及 Linux ARM64 Server 和容器镜像。官网下载区可直接选择对应架构,并显示最新版本和发布时间。
- 更新官网演示、README 截图和中英文教程,明确浏览器分享也可选择窗口或屏幕;开始分享前显示房间号占位,保持底栏控件位置稳定。
- 官网新增直达问题排查的入口,补充浏览器 WebRTC 限制、校园网和 Windows 显卡相关的排查步骤。
- App 模式选择页显示免费开源说明、作者与贡献者署名,以及官方网站和源码链接。

#### 修复

- 修正部分 H.264 原生分享的格式兼容性校验,以及 Windows 编码器输入长度和输出缓冲区对齐问题。
- 清理已失效的重连任务和 Native 接收信令,避免旧连接继续影响当前会话。
- 修复 App 重连时可能复用过期公网地址的问题;重新探测使用当前地址,并处理探测丢包和过期响应,保留其他正常连接。
- 修复 App 新旧媒体连接重叠时共用端口造成的冲突,避免准备或撤销新连接打断仍在使用的画面;关闭连接时及时取消未完成的端口映射请求。
- 无站点密码时,连接恢复不再误引导用户输入站点密码。
- 房间失效或会话终止时,开播前后的原因提示统一显示在电视状态图标中,避免额外提示条。
- 修正 Linux 原生采集的时间戳与关键帧时间基准。感谢 Pedro Antonio 在 [#449](https://github.com/TNTcraftHIM/Piik/pull/449) 的贡献。

#### 使用说明

下载对应平台的完整压缩包,退出旧 App 后解压使用。Windows 继续提供 x64;macOS 提供 Apple 芯片与 Intel 版本,原生采集需要 macOS 13 或更新版本。Linux 提供 x64 与 ARM64,原生采集还需安装[系统组件](https://github.com/TNTcraftHIM/Piik/blob/main/native/capture/linux/README.md)。macOS 和 Linux 的实机覆盖仍有限,欢迎反馈。

### English

This update adds useful microphone and chat controls, brings Piik to more devices, and fixes several native-sharing and connection-recovery issues. The existing 1.x protocols remain unchanged.

#### New and improved

- Browser microphones now have a “Noise & echo reduction” toggle. Echo cancellation, noise suppression and automatic gain control are on by default; turn them off when appropriate for music or virtual audio inputs.
- Adjust chat-overlay text size and opacity, and export retained messages as TXT. The current session keeps up to 1,000 text messages and clears on page refresh; export first if you want to keep them.
- Added Intel Mac and Linux ARM64 App packages, plus Linux ARM64 Server and container images. The website provides architecture selectors and shows the latest release and publication date.
- Updated the website demonstration, README screenshots and bilingual guides, clarifying that browser capture can also select windows or screens. A room-code placeholder keeps the bottom bar stable before sharing starts.
- Added direct website links to troubleshooting, with steps for browser WebRTC restrictions, campus networks and Windows graphics issues.
- The App mode-selection page now shows its free and open-source status, author and contributor credit, and links to the official website and source.

#### Fixes

- Corrected H.264 format compatibility checks and Windows encoder input-length and output-buffer alignment handling.
- Retired stale reconnect tasks and Native receive signals so they cannot continue affecting the current session.
- Fixed App reconnection reusing an expired public address. Fresh discovery handles lost probes and stale responses while preserving other working connections.
- Fixed UDP socket conflicts between overlapping App media connections so preparing or canceling a replacement preserves the active picture. Closing a connection now cancels unfinished gateway mapping requests.
- Connection recovery on passwordless sites no longer incorrectly asks for a site passphrase.
- Expired-room and session-termination reasons now use the same TV status indicator before and during sharing, without an extra notice strip.
- Corrected Linux native capture timestamps and keyframe time bases. Thanks to Pedro Antonio for [#449](https://github.com/TNTcraftHIM/Piik/pull/449).

#### Updating

Download the complete archive for your platform, quit the old App, and extract the new package. Windows remains x64-only. macOS packages support Apple silicon and Intel; native capture requires macOS 13 or newer. Linux supports x64 and ARM64 and requires additional [system components](https://github.com/TNTcraftHIM/Piik/blob/main/native/capture/linux/README.md) for native capture. Physical-device coverage on macOS and Linux remains limited; feedback is welcome.

## Attribution

Co-authored-by: Pedro Antonio <wrenchwastaken@gmail.com>
This commit is contained in:
AAAandPedro Antonio authored and GitHub committed 2026-10-04 16:21:01 +08:00
1 parent 1b9f5bd2a2
commit c18502fd18
143 files changed
+3825 -1007

No files matched your search

@@ -14,7 +14,7 @@ const (
)
type localCandidateGathering struct {
engine *Engine
socket *iceSocket
servers []webrtc.ICEServer
mappedPort int
prepareMapping func() int
@@ -32,15 +32,15 @@ type localCandidateGathering struct {
}
func newLocalCandidateGathering(
engine *Engine,
socket *iceSocket,
servers []webrtc.ICEServer,
prepareMapping func() int,
emit func(*webrtc.ICECandidateInit),
) *localCandidateGathering {
ctx, cancel := context.WithCancel(engine.ctx)
ctx, cancel := context.WithCancel(socket.ctx)
surveyServers := stunServers(servers)
return &localCandidateGathering{
engine: engine, servers: surveyServers, prepareMapping: prepareMapping, emit: emit,
socket: socket, servers: surveyServers, prepareMapping: prepareMapping, emit: emit,
ctx: ctx, cancel: cancel, supplementalDone: len(surveyServers) == 0,
mappedCandidates: make(map[string]struct{}),
}
@@ -85,7 +85,7 @@ func (gathering *localCandidateGathering) emitMappedCandidate(
" 1 udp " + strconv.Itoa(mappedCandidatePriority) + " " +
mapped.address + " " + strconv.Itoa(gathering.mappedPort) +
" typ srflx raddr 0.0.0.0 rport " +
strconv.Itoa(gathering.engine.localPort),
strconv.Itoa(gathering.socket.localPort),
SDPMid: &mid, SDPMLineIndex: &line,
})
}
@@ -110,7 +110,7 @@ func (gathering *localCandidateGathering) start() {
survey := make(chan mappedAddress)
go func() {
defer close(survey)
gathering.engine.surveySTUN(gathering.ctx, gathering.servers, func(mapped mappedAddress) {
gathering.socket.surveySTUN(gathering.ctx, gathering.servers, func(mapped mappedAddress) {
select {
case survey <- mapped:
case <-gathering.ctx.Done():
@@ -143,7 +143,7 @@ func (gathering *localCandidateGathering) start() {
" 1 udp " + strconv.Itoa(surveyCandidatePriority) + " " +
mapped.address + " " +
strconv.Itoa(mapped.port) + " typ srflx raddr 0.0.0.0 rport " +
strconv.Itoa(gathering.engine.localPort),
strconv.Itoa(gathering.socket.localPort),
SDPMid: &mid, SDPMLineIndex: &line,
})
if mapping != nil {
@@ -42,11 +42,12 @@ func TestCandidatesTrickleBeforeGatewayMappingCompletes(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
mapping := make(chan int, 1)
t.Cleanup(func() { close(mapping) })
started := make(chan struct{})
candidates := make(chan *webrtc.ICECandidateInit, 8)
gathering := newLocalCandidateGathering(engine,
gathering := newLocalCandidateGathering(socket,
[]webrtc.ICEServer{{URLs: []string{localSurveyServer(t)}}},
func() int { close(started); return <-mapping },
func(candidate *webrtc.ICECandidateInit) { candidates <- candidate },
@@ -56,7 +57,7 @@ func TestCandidatesTrickleBeforeGatewayMappingCompletes(t *testing.T) {
waitSignal(t, started, "mapping start")
gathering.addPion(&webrtc.ICECandidate{
Foundation: "host", Component: 1, Protocol: webrtc.ICEProtocolUDP,
Address: "127.0.0.1", Port: uint16(engine.localPort), Typ: webrtc.ICECandidateTypeHost,
Address: "127.0.0.1", Port: uint16(socket.localPort), Typ: webrtc.ICECandidateTypeHost,
})
gathering.addPion(nil)
for received := 0; received < 2; received++ {
@@ -78,7 +79,7 @@ func TestCandidatesTrickleBeforeGatewayMappingCompletes(t *testing.T) {
if closeFirst {
gathering.close()
}
mapping <- engine.localPort%65535 + 1
mapping <- socket.localPort%65535 + 1
if closeFirst {
select {
case candidate := <-candidates:
@@ -114,6 +115,7 @@ func TestSurveyHealthySTUNDoesNotWaitForSlowDNS(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
original := net.DefaultResolver
t.Cleanup(func() { net.DefaultResolver = original })
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -126,11 +128,11 @@ func TestSurveyHealthySTUNDoesNotWaitForSlowDNS(t *testing.T) {
started := time.Now()
go func() {
defer close(done)
engine.surveySTUN(t.Context(), servers, func(mapped mappedAddress) { observations <- mapped })
socket.surveySTUN(t.Context(), servers, func(mapped mappedAddress) { observations <- mapped })
}()
select {
case mapped := <-observations:
if mapped.port != engine.localPort {
if mapped.port != socket.localPort {
t.Fatal("STUN did not use the shared media socket")
}
case <-time.After(time.Second):
@@ -159,8 +161,9 @@ func TestSurveyReturnsWhenEveryDestinationAlreadyFailed(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
started := time.Now()
engine.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:127.0.0.1:9"}}},
socket.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:127.0.0.1:9"}}},
func(mappedAddress) { t.Error("failed transport emitted a candidate") })
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("completed failure retained its survey timer: %v", elapsed)
+23 -7
View File
@@ -32,6 +32,7 @@ type EdgeOptions struct {
type Edge struct {
connectionID string
engine *Engine
socket *iceSocket
source *Source
audioSource *AudioSource
connection *webrtc.PeerConnection
@@ -52,7 +53,7 @@ type Edge struct {
localCandidates *localCandidateGathering
}
func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error) {
func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (_ *Edge, err error) {
if source == nil || source.engine != engine || options.ConnectionID == "" ||
len(options.ConnectionID) > 256 {
return nil, errors.New("native media edge input is invalid")
@@ -60,10 +61,6 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
if options.Audio != nil && options.Audio.engine != engine {
return nil, errors.New("native audio edge source belongs to another engine")
}
var prepareMapping func() int
if engine.portMapping != nil && !options.Local && len(options.ICEServers) > 0 {
prepareMapping = engine.portMapping.Prepare
}
if err := source.reserve(options.Local); err != nil {
return nil, err
}
@@ -73,12 +70,29 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
return nil, err
}
}
socket, err := engine.newICESocket(options.ICEServers, !options.Local)
if err != nil {
source.releaseReservation(options.Local)
if options.Audio != nil {
options.Audio.releaseReservation(options.Local)
}
return nil, err
}
defer func() {
if err != nil {
socket.close()
}
}()
var prepareMapping func() int
if socket.portMapping != nil {
prepareMapping = socket.portMapping.Prepare
}
var audio webrtc.TrackLocal
if options.Audio != nil {
audio = options.Audio.track
}
transport, err := forwarding.NewTransport(forwarding.TransportOptions{
Source: source.media.Source, Settings: engine.settings, ConnectionID: options.ConnectionID,
Source: source.media.Source, Settings: socket.settings(), ConnectionID: options.ConnectionID,
InitialBitrate: engine.initialBitrate, Audio: audio,
})
if err != nil {
@@ -94,6 +108,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
edge := &Edge{
connectionID: options.ConnectionID,
engine: engine,
socket: socket,
source: source,
audioSource: options.Audio,
connection: connection,
@@ -129,7 +144,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
}
}
edge.localCandidates = newLocalCandidateGathering(
engine,
socket,
options.ICEServers,
prepareMapping,
options.Events.LocalCandidate,
@@ -363,6 +378,7 @@ func (edge *Edge) Close() error {
logLocalMediaCandidateCounts(edge.connection.GetStats())
}
err := edge.transport.Close()
edge.socket.close()
edge.source.detach(edge)
if edge.audioSource != nil {
edge.audioSource.detach(edge)
+2 -2
View File
@@ -279,7 +279,7 @@ func TestShutdownClosesSiblingTransportsBeforeWaitingForWrites(t *testing.T) {
func TestPortMappingCandidateUsesObservedPublicAddress(t *testing.T) {
var emitted []*webrtc.ICECandidateInit
gathering := &localCandidateGathering{
engine: &Engine{localPort: 43210},
socket: &iceSocket{localPort: 43210},
mappedPort: 43211,
mappedCandidates: make(map[string]struct{}),
emit: func(candidate *webrtc.ICECandidateInit) {
@@ -333,7 +333,7 @@ func testEncodedSourceFanout(t *testing.T, codec string) {
edgeB, receiverB, packetB := connectedReceiver(t, engine, source, "edge-b")
t.Cleanup(func() { _ = receiverA.Close() })
t.Cleanup(func() { _ = receiverB.Close() })
if edgeA.connection == edgeB.connection || engine.ListenAddress() == "" {
if edgeA.connection == edgeB.connection || edgeA.socket == edgeB.socket || edgeA.socket.localPort == edgeB.socket.localPort {
t.Fatal("media edges did not keep independent transports")
}
if edgeA.transport == edgeB.transport {
+26 -155
View File
@@ -3,28 +3,22 @@ package mediaedge
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/TNTcraftHIM/Piik/internal/app/portmapping"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/rtcp"
"github.com/pion/stun/v3"
"github.com/pion/webrtc/v4"
)
const H264ProfileLevelID = "42c033"
const stunSurveyTimeout = 5 * time.Second
// 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"
var h264Capability = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
@@ -58,12 +52,12 @@ type EngineOptions struct {
}
type Engine struct {
api *webrtc.API
media *webrtc.MediaEngine
interceptors *interceptor.Registry
settings webrtc.SettingEngine
mux *ice.UniversalUDPMuxDefault
listenAddress string
localPort int
portMapping *portmapping.Mapping
bindAddress string
portMapping bool
listenPacket func(string, string) (net.PacketConn, error)
initialBitrate int
ctx context.Context
cancel context.CancelFunc
@@ -71,6 +65,7 @@ type Engine struct {
mu sync.Mutex
edges map[*Edge]struct{}
publications map[*Publication]struct{}
sockets map[*iceSocket]struct{}
closed bool
}
@@ -79,44 +74,27 @@ func NewEngine(options EngineOptions) (*Engine, error) {
if bindAddress == "" {
bindAddress = ":0"
}
// Go keeps wildcard UDP on one dual-stack socket where supported.
// Concrete IPv4 bindings remain IPv4-only.
address, err := net.ResolveUDPAddr("udp", bindAddress)
if err != nil {
if _, err := net.ResolveUDPAddr("udp", bindAddress); err != nil {
return nil, errors.New("native media UDP bind address is invalid")
}
connection, err := net.ListenUDP("udp", address)
if err != nil {
return nil, errors.New("native media UDP socket is unavailable")
}
loggerFactory := diagnostics.PionLoggerFactory()
ready := make(chan struct{})
mux := ice.NewUniversalUDPMuxDefault(ice.UniversalUDPMuxParams{
Logger: loggerFactory.NewLogger("piik-ice"),
UDPConn: &initializingUDPConn{UDPConn: connection, ready: ready},
})
close(ready)
settingEngine := webrtc.SettingEngine{LoggerFactory: loggerFactory}
settingEngine.SetICEUDPMux(mux)
settingEngine.SetIncludeLoopbackCandidate(options.IncludeLoopback)
mediaEngine := &webrtc.MediaEngine{}
for _, codec := range []string{"h264", "vp8"} {
if err = mediaEngine.RegisterCodec(videoCodecs[codec], webrtc.RTPCodecTypeVideo); err != nil {
_ = mux.Close()
if err := mediaEngine.RegisterCodec(videoCodecs[codec], webrtc.RTPCodecTypeVideo); err != nil {
return nil, err
}
}
if err = mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: opusCapability,
PayloadType: 111,
}, webrtc.RTPCodecTypeAudio); err != nil {
_ = mux.Close()
return nil, err
}
registry := &interceptor.Registry{}
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
_ = mux.Close()
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
@@ -124,129 +102,22 @@ func NewEngine(options EngineOptions) (*Engine, error) {
options.InitialBitrate = defaultNativeSourceInitialBitrate
}
engine := &Engine{
api: webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithInterceptorRegistry(registry),
webrtc.WithSettingEngine(settingEngine),
),
mux: mux,
media: mediaEngine,
interceptors: registry,
settings: settingEngine,
listenAddress: connection.LocalAddr().String(),
localPort: connection.LocalAddr().(*net.UDPAddr).Port,
bindAddress: bindAddress,
listenPacket: net.ListenPacket,
portMapping: options.PortMapping,
initialBitrate: options.InitialBitrate,
ctx: ctx,
cancel: cancel,
edges: make(map[*Edge]struct{}),
publications: make(map[*Publication]struct{}),
}
if options.PortMapping {
engine.portMapping = portmapping.Start(connection.LocalAddr().(*net.UDPAddr).Port)
sockets: make(map[*iceSocket]struct{}),
}
return engine, nil
}
// Pion starts its reader before publishing the embedded UDP mux. Both receive
// paths wait for construction; the AddrPort interface preserves its fast path.
type initializingUDPConn struct {
*net.UDPConn
ready <-chan struct{}
}
var _ ice.AddrPortReaderWriter = (*initializingUDPConn)(nil)
func (connection *initializingUDPConn) ReadFrom(buffer []byte) (int, net.Addr, error) {
<-connection.ready
return connection.UDPConn.ReadFrom(buffer)
}
func (connection *initializingUDPConn) ReadFromAddrPort(buffer []byte) (int, netip.AddrPort, error) {
<-connection.ready
return connection.UDPConn.ReadFromUDPAddrPort(buffer)
}
func (connection *initializingUDPConn) WriteToAddrPort(buffer []byte, address netip.AddrPort) (int, error) {
return connection.UDPConn.WriteToUDPAddrPort(buffer, address)
}
type mappedAddress struct {
address string
port int
}
func (engine *Engine) surveySTUN(
ctx context.Context,
servers []webrtc.ICEServer,
emit func(mappedAddress),
) {
surveyContext, cancel := context.WithTimeout(ctx, stunSurveyTimeout)
defer cancel()
// One survey retains its resolver even while canceled workers are settling.
resolver := net.DefaultResolver
results := make(chan mappedAddress)
var pending sync.WaitGroup
for _, server := range servers {
for _, rawURL := range server.URLs {
uri, err := stun.ParseURI(rawURL)
if err != nil || uri.Scheme != stun.SchemeTypeSTUN ||
uri.Proto != stun.ProtoTypeUDP || uri.Port < 1 || uri.Port > 65_535 {
continue
}
pending.Add(1)
go func() {
defer pending.Done()
// Resolve each destination independently inside the same bound as
// its Binding request; one broken resolver must not stall healthy STUN.
addresses, err := resolver.LookupNetIP(surveyContext, "ip4", uri.Host)
if err != nil || len(addresses) == 0 {
slog.DebugContext(surveyContext, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err))
return
}
address := net.UDPAddrFromAddrPort(netip.AddrPortFrom(addresses[0], uint16(uri.Port)))
started := time.Now()
mapped, err := engine.mux.GetXORMappedAddrContext(
surveyContext,
address,
stunSurveyTimeout,
)
if err != nil || mapped == nil || mapped.IP.To4() == nil ||
mapped.Port < 1 || mapped.Port > 65_535 {
slog.DebugContext(surveyContext, "nat-survey", "event", "binding-failed", "serverPort", address.Port,
"durationMs", time.Since(started).Milliseconds(), diagnostics.Error(err))
return
}
slog.DebugContext(surveyContext, "nat-survey", "event", "binding", "serverPort", address.Port,
"localPort", engine.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 <-surveyContext.Done():
}
}()
}
}
go func() {
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{}{}
emit(value)
case <-surveyContext.Done():
return
}
}
}
func stunServers(servers []webrtc.ICEServer) []webrtc.ICEServer {
result := make([]webrtc.ICEServer, 0, len(servers))
for _, server := range servers {
@@ -263,10 +134,6 @@ func stunServers(servers []webrtc.ICEServer) []webrtc.ICEServer {
return result
}
func (engine *Engine) ListenAddress() string {
return engine.listenAddress
}
// 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) {
@@ -346,13 +213,17 @@ func (engine *Engine) Close() error {
for publication := range engine.publications {
publications = append(publications, publication)
}
sockets := make([]*iceSocket, 0, len(engine.sockets))
for socket := range engine.sockets {
sockets = append(sockets, socket)
}
engine.mu.Unlock()
closeEdges(edges)
for _, publication := range publications {
_ = publication.Close()
}
if engine.portMapping != nil {
engine.portMapping.Close()
for _, socket := range sockets {
socket.close()
}
return engine.mux.Close()
return nil
}
+19 -9
View File
@@ -12,6 +12,15 @@ import (
"github.com/pion/webrtc/v4"
)
func testICESocket(t *testing.T, engine *Engine) *iceSocket {
t.Helper()
socket, err := engine.newICESocket(nil, false)
if err != nil {
t.Fatal(err)
}
return socket
}
func TestEngineMediaAddressFamilies(t *testing.T) {
for _, test := range []struct {
name, bind, receiver string
@@ -58,12 +67,12 @@ func TestEngineMediaAddressFamilies(t *testing.T) {
}
if (net.ParseIP(pair.Local.Address).To4() == nil) != test.ipv6 ||
(net.ParseIP(pair.Remote.Address).To4() == nil) != test.ipv6 ||
int(pair.Local.Port) != engine.localPort {
int(pair.Local.Port) != edge.socket.localPort {
t.Fatal("ICE selected a different address family or media socket")
}
for _, stats := range edge.connection.GetStats() {
if candidate, ok := stats.(webrtc.ICECandidateStats); ok && candidate.Type == webrtc.StatsTypeLocalCandidate &&
int(candidate.Port) != engine.localPort {
int(candidate.Port) != edge.socket.localPort {
t.Fatal("ICE candidate did not use the shared media port")
}
}
@@ -79,7 +88,7 @@ func TestEngineMediaAddressFamilies(t *testing.T) {
if edge.State() != webrtc.PeerConnectionStateClosed {
t.Fatal("engine shutdown retained its media edge")
}
address, err := net.ResolveUDPAddr("udp", engine.ListenAddress())
address, err := net.ResolveUDPAddr("udp", edge.socket.mux.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
@@ -92,12 +101,13 @@ func TestEngineMediaAddressFamilies(t *testing.T) {
}
}
func TestDefaultEngineSTUNUsesOnePortForBothFamilies(t *testing.T) {
func TestICESocketSTUNUsesOnePortForBothFamilies(t *testing.T) {
engine, err := NewEngine(EngineOptions{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
for _, test := range []struct {
network string
ip net.IP
@@ -106,7 +116,7 @@ func TestDefaultEngineSTUNUsesOnePortForBothFamilies(t *testing.T) {
{network: "udp6", ip: net.IPv6loopback},
} {
t.Run(test.network, func(t *testing.T) {
bound, err := net.ResolveUDPAddr("udp", engine.ListenAddress())
bound, err := net.ResolveUDPAddr("udp", socket.mux.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
@@ -129,19 +139,19 @@ func TestDefaultEngineSTUNUsesOnePortForBothFamilies(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
mapped, err := engine.mux.GetXORMappedAddrContext(t.Context(), listener.LocalAddr(), time.Second)
mapped, err := socket.stunMapping(t.Context(), listener.LocalAddr().(*net.UDPAddr))
if err != nil {
t.Fatal(err)
}
if mapped.Port != engine.localPort || !mapped.IP.Equal(test.ip) {
if mapped.Port != socket.localPort || !mapped.IP.Equal(test.ip) {
t.Fatal("STUN observed a different socket or address family")
}
if test.network == "udp4" {
observations := 0
engine.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:" + listener.LocalAddr().String()}}},
socket.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:" + listener.LocalAddr().String()}}},
func(mapped mappedAddress) {
observations++
if mapped.port != engine.localPort || net.ParseIP(mapped.address).To4() == nil {
if mapped.port != socket.localPort || net.ParseIP(mapped.address).To4() == nil {
t.Error("Native survey did not retain its IPv4 media socket")
}
})
+4 -3
View File
@@ -2,6 +2,7 @@ package mediaedge
import (
"errors"
"fmt"
"log/slog"
"time"
@@ -198,7 +199,7 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) {
if selected.budget != demand.budget || source.memberships[demand.consumer] != selected {
if slog.Default().Enabled(source.engine.ctx, slog.LevelDebug) {
slog.Debug("encoding-group", "event", "demand", "slot", selected.slot, "budget", demand.budget,
"localPort", source.engine.localPort, "rtcPeerId", groupConsumerID(demand.consumer))
"sourceId", diagnostics.ID(fmt.Sprintf("%p", source)), "rtcPeerId", groupConsumerID(demand.consumer))
}
}
selected.budget = demand.budget
@@ -223,7 +224,7 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) {
}
nextActive := active && source.outputBitrates[group.slot] > 0
if group.active != nextActive {
slog.Debug("encoding-group", "event", "activity", "slot", group.slot, "active", nextActive, "localPort", source.engine.localPort)
slog.Debug("encoding-group", "event", "activity", "slot", group.slot, "active", nextActive, "sourceId", diagnostics.ID(fmt.Sprintf("%p", source)))
}
group.active = nextActive
plan.Active[group.slot] = group.active
@@ -286,7 +287,7 @@ func (source *Source) installGroup(group *outputGroup, original bool) {
slot = group.slot
}
slog.Debug("encoding-group", "event", "attachment", "slot", slot, "original", original,
"localPort", source.engine.localPort, "rtcPeerId", groupConsumerID(consumer), diagnostics.Error(err))
"sourceId", diagnostics.ID(fmt.Sprintf("%p", source)), "rtcPeerId", groupConsumerID(consumer), diagnostics.Error(err))
}
}
}
+173
View File
@@ -0,0 +1,173 @@
package mediaedge
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"strconv"
"sync"
"time"
"github.com/TNTcraftHIM/Piik/internal/app/portmapping"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/pion/ice/v4"
"github.com/pion/stun/v3"
"github.com/pion/webrtc/v4"
"golang.org/x/sync/singleflight"
)
const stunSurveyTimeout = 5 * time.Second
// One physical connection owns one socket: Pion's UDP mux cannot distinguish
// concurrent DTLS/SRTP sessions with the same remote IP:port. Discovery and
// media share this socket; ICE restart retains it, replacement never does.
type iceSocket struct {
engine *Engine
mux *ice.UDPMuxDefault
localPort int
portMapping *portmapping.Mapping
stunQueries singleflight.Group
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
}
func (engine *Engine) newICESocket(servers []webrtc.ICEServer, mapping bool) (*iceSocket, error) {
// Go keeps wildcard UDP dual-stack where supported; a concrete IPv4 bind
// remains IPv4-only. The same address family selection serves every edge.
connection, err := engine.listenPacket("udp", engine.bindAddress)
if err != nil {
return nil, errors.New("native media UDP socket is unavailable")
}
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{
Logger: diagnostics.PionLoggerFactory().NewLogger("piik-ice"), UDPConn: connection,
}),
}
if engine.portMapping && mapping && len(stunServers(servers)) > 0 {
socket.portMapping = portmapping.Start(socket.localPort)
}
engine.mu.Lock()
closed := engine.closed
if !closed {
engine.sockets[socket] = struct{}{}
}
engine.mu.Unlock()
if closed {
socket.close()
return nil, errors.New("native media engine is closed")
}
return socket, nil
}
func (socket *iceSocket) settings() webrtc.SettingEngine {
settings := socket.engine.settings
settings.SetICEUDPMux(socket.mux)
return settings
}
func (socket *iceSocket) newPeerConnection() (*webrtc.PeerConnection, error) {
return webrtc.NewAPI(
webrtc.WithMediaEngine(socket.engine.media),
webrtc.WithInterceptorRegistry(socket.engine.interceptors),
webrtc.WithSettingEngine(socket.settings()),
).NewPeerConnection(webrtc.Configuration{})
}
func (socket *iceSocket) close() {
if socket == nil {
return
}
socket.closeOnce.Do(func() {
socket.cancel()
// Keep the port reserved until gateway cleanup settles: a late deletion
// must not remove a replacement connection's mapping on a recycled port.
if socket.portMapping != nil {
socket.portMapping.Close()
}
_ = socket.mux.Close()
socket.engine.mu.Lock()
delete(socket.engine.sockets, socket)
socket.engine.mu.Unlock()
})
}
type mappedAddress struct {
address string
port int
}
func (socket *iceSocket) surveySTUN(
ctx context.Context,
servers []webrtc.ICEServer,
emit func(mappedAddress),
) {
surveyContext, cancel := context.WithTimeout(ctx, stunSurveyTimeout)
defer cancel()
// One survey retains its resolver even while canceled workers are settling.
resolver := net.DefaultResolver
results := make(chan mappedAddress)
var pending sync.WaitGroup
for _, server := range servers {
for _, rawURL := range server.URLs {
uri, err := stun.ParseURI(rawURL)
if err != nil || uri.Scheme != stun.SchemeTypeSTUN ||
uri.Proto != stun.ProtoTypeUDP || uri.Port < 1 || uri.Port > 65_535 {
continue
}
pending.Add(1)
go func() {
defer pending.Done()
// Resolve each destination independently inside the same bound as
// its Binding request; one broken resolver must not stall healthy STUN.
addresses, err := resolver.LookupNetIP(surveyContext, "ip4", uri.Host)
if err != nil || len(addresses) == 0 {
slog.DebugContext(surveyContext, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err))
return
}
address := net.UDPAddrFromAddrPort(netip.AddrPortFrom(addresses[0], uint16(uri.Port)))
started := time.Now()
mapped, err := socket.stunMapping(surveyContext, address)
if err != nil || mapped == nil || mapped.IP.To4() == nil ||
mapped.Port < 1 || mapped.Port > 65_535 {
slog.DebugContext(surveyContext, "nat-survey", "event", "binding-failed", "serverPort", address.Port,
"durationMs", time.Since(started).Milliseconds(), diagnostics.Error(err))
return
}
slog.DebugContext(surveyContext, "nat-survey", "event", "binding", "serverPort", address.Port,
"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 <-surveyContext.Done():
}
}()
}
}
go func() {
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{}{}
emit(value)
case <-surveyContext.Done():
return
}
}
}
+9 -2
View File
@@ -63,12 +63,18 @@ func (engine *Engine) NewPublication(source *Source, options EdgeOptions) (*Publ
}
}
publication := &Publication{source: source, audio: options.Audio}
socket, err := engine.newICESocket(options.ICEServers, false)
if err != nil {
_ = publication.Close()
return nil, err
}
publication.signaling.socket = socket
var audio webrtc.TrackLocal
if options.Audio != nil {
audio = options.Audio.track
}
transport, err := forwarding.NewPublication(forwarding.TransportOptions{
Source: source.media.Source, Settings: engine.settings, ConnectionID: options.ConnectionID,
Source: source.media.Source, Settings: socket.settings(), ConnectionID: options.ConnectionID,
InitialBitrate: engine.initialBitrate, Audio: audio,
})
if err != nil {
@@ -78,7 +84,7 @@ func (engine *Engine) NewPublication(source *Source, options EdgeOptions) (*Publ
publication.transport = transport
transport.SetAudioBitrate(options.Audio.configuredBitrate())
publication.signaling.connection = transport.PC
publication.signaling.localCandidates = newLocalCandidateGathering(engine, options.ICEServers, nil, options.Events.LocalCandidate)
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) {
if state == webrtc.PeerConnectionStateConnected {
@@ -216,6 +222,7 @@ func (publication *Publication) Close() error {
if publication.transport != nil {
publication.closeErr = publication.transport.Close()
}
publication.signaling.socket.close()
publication.source.mu.Lock()
delete(publication.source.publications, publication)
publication.source.mu.Unlock()
+90 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/interceptor"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
@@ -52,8 +53,8 @@ func TestPublicationOwnsOneReservationAndSourceLifetime(t *testing.T) {
if _, err = engine.NewPublication(source, EdgeOptions{ConnectionID: "third"}); err == nil {
t.Fatal("third publication was admitted")
}
if source.reservations != 2 {
t.Fatal("failed publication leaked its reservation")
if source.reservations != 2 || len(engine.sockets) != 2 {
t.Fatal("failed publication leaked its reservation or socket")
}
edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "sibling"})
if err != nil {
@@ -81,7 +82,7 @@ func TestPublicationOwnsOneReservationAndSourceLifetime(t *testing.T) {
if _, err = engine.NewPublication(source, EdgeOptions{ConnectionID: "failed-output"}); err == nil {
t.Fatal("failed output was admitted into publication metadata")
}
if source.reservations != 0 || len(source.publications) != 0 {
if source.reservations != 0 || len(source.publications) != 0 || len(engine.sockets) != 1 {
t.Fatal("failed publication demand retained a reservation")
}
if err = source.ConfigureOutputs([]uint32{90_000, 300_000}); err != nil {
@@ -94,11 +95,96 @@ func TestPublicationOwnsOneReservationAndSourceLifetime(t *testing.T) {
if err = source.Close(); err != nil {
t.Fatal(err)
}
if replacement.signaling.connection.ConnectionState() != webrtc.PeerConnectionStateClosed || len(engine.publications) != 0 || source.reservations != 0 {
if replacement.signaling.connection.ConnectionState() != webrtc.PeerConnectionStateClosed || len(engine.publications) != 0 || source.reservations != 0 || len(engine.sockets) != 0 {
t.Fatal("source retirement did not release the publication")
}
}
func TestPublicationOverlapToSharedSFUSocket(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() })
source, err := engine.NewSource("vp8", 2, 1, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
if err = source.SetFormat(0, 8, 8); err != nil {
t.Fatal(err)
}
if err = source.ConfigureOutputs([]uint32{90_000}); err != nil {
t.Fatal(err)
}
server, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
// The embedded SFU deliberately keeps its shared listener. Native publication
// replacement must work against that unchanged, single-port remote endpoint.
sfuSocket := testICESocket(t, server)
for _, uri := range []string{"urn:ietf:params:rtp-hdrext:sdes:mid", "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id"} {
if err = server.media.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: uri}, webrtc.RTPCodecTypeVideo); err != nil {
t.Fatal(err)
}
}
connect := func(id string) (*Publication, <-chan *rtp.Packet) {
t.Helper()
publication, err := engine.NewPublication(source, EdgeOptions{ConnectionID: id})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = publication.Close() })
receiver, err := sfuSocket.newPeerConnection()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
packets := make(chan *rtp.Packet, 8)
receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
for {
packet, _, err := track.ReadRTP()
if err != nil {
return
}
select {
case packets <- packet:
default:
}
}
})
connectEdgeToReceiver(t, &publication.signaling, receiver)
return publication, packets
}
pts := time.Duration(0)
delivery := func(streams ...<-chan *rtp.Packet) {
t.Helper()
for _, packets := range streams {
for len(packets) > 0 {
<-packets
}
}
for range 20 {
pts += time.Second / 30
if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, pts, time.Second/30); err != nil {
t.Fatal(err)
}
time.Sleep(time.Second / 30)
}
for _, packets := range streams {
_ = waitPacket(t, packets)
}
}
_, current := connect("current")
delivery(current)
candidate, pending := connect("candidate")
delivery(current, pending)
_ = candidate.Close()
delivery(current)
}
func TestPublicationAdmissionIgnoresUnrelatedFailedOutputs(t *testing.T) {
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
if err != nil {
+57 -22
View File
@@ -4,9 +4,12 @@ import (
"errors"
"io"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
"github.com/pion/rtcp"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
@@ -30,7 +33,7 @@ type ReceiverOptions struct {
// ordinary bounded Sources. Consumers can attach local playback and downstream
// P2P edges without adding a decoder or encoder.
type Receiver struct {
engine *Engine
socket *iceSocket
connection *webrtc.PeerConnection
source *Source
audioSource *AudioSource
@@ -46,7 +49,7 @@ type Receiver struct {
videoSSRC webrtc.SSRC
}
func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.SessionDescription, error) {
func (engine *Engine) NewReceiver(options ReceiverOptions) (_ *Receiver, _ webrtc.SessionDescription, err error) {
if options.Offer.Type != webrtc.SDPTypeOffer || options.Offer.SDP == "" {
return nil, webrtc.SessionDescription{}, errors.New("native media receiver input is invalid")
}
@@ -55,10 +58,19 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se
return nil, webrtc.SessionDescription{}, err
}
var prepareMapping func() int
if engine.portMapping != nil {
prepareMapping = engine.portMapping.Prepare
socket, err := engine.newICESocket(options.ICEServers, true)
if err != nil {
return nil, webrtc.SessionDescription{}, err
}
connection, err := engine.api.NewPeerConnection(webrtc.Configuration{})
defer func() {
if err != nil {
socket.close()
}
}()
if socket.portMapping != nil {
prepareMapping = socket.portMapping.Prepare
}
connection, err := socket.newPeerConnection()
if err != nil {
return nil, webrtc.SessionDescription{}, err
}
@@ -72,7 +84,7 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se
return nil, webrtc.SessionDescription{}, err
}
receiver := &Receiver{
engine: engine,
socket: socket,
connection: connection,
events: options.Events,
iceServers: options.ICEServers,
@@ -201,7 +213,7 @@ func (receiver *Receiver) beginGathering() {
defer receiver.mu.Unlock()
return !receiver.closed && receiver.localCandidates == gathering
}
gathering = newLocalCandidateGathering(receiver.engine, receiver.iceServers, receiver.prepareMapping,
gathering = newLocalCandidateGathering(receiver.socket, receiver.iceServers, receiver.prepareMapping,
func(candidate *webrtc.ICECandidateInit) {
receiver.candidateMu.Lock()
defer receiver.candidateMu.Unlock()
@@ -320,11 +332,13 @@ func (receiver *Receiver) Close() error {
if localCandidates != nil {
localCandidates.close()
}
err := receiver.connection.Close()
receiver.socket.close()
_ = receiver.source.Close()
if receiver.audioSource != nil {
_ = receiver.audioSource.Close()
}
return receiver.connection.Close()
return err
}
func (receiver *Receiver) consumeTrack(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
@@ -387,7 +401,10 @@ func offerSendsCodec(raw, kind, codecName string) (bool, error) {
return false, errors.New("native media receiver SDP is invalid")
}
for _, media := range description.MediaDescriptions {
if media.MediaName.Media != kind || media.MediaName.Port.Value == 0 {
// A zero-port bundle-only section still offers media on the bundle's
// transport (RFC 9143). Only an ordinary zero port rejects the track.
_, bundleOnly := media.Attribute("bundle-only")
if media.MediaName.Media != kind || (media.MediaName.Port.Value == 0 && !bundleOnly) {
continue
}
if _, inactive := media.Attribute("inactive"); inactive {
@@ -396,15 +413,13 @@ func offerSendsCodec(raw, kind, codecName string) (bool, error) {
if _, receiveOnly := media.Attribute("recvonly"); receiveOnly {
continue
}
for _, attribute := range media.Attributes {
if attribute.Key != "rtpmap" {
codecs := (&sdp.SessionDescription{MediaDescriptions: []*sdp.MediaDescription{media}}).GetCodecMap()
for payload, codec := range codecs {
if !slices.Contains(media.MediaName.Formats, strconv.Itoa(int(payload))) ||
!strings.EqualFold(codec.Name, codecName) {
continue
}
fields := strings.Fields(attribute.Value)
if len(fields) < 2 || !containsString(media.MediaName.Formats, fields[0]) {
continue
}
if strings.HasPrefix(strings.ToLower(fields[1]), codecName+"/") {
if codecName != "h264" || forwardableH264(codec.Fmtp) {
return true, nil
}
}
@@ -419,12 +434,16 @@ func selectReceiverVideoCodec(connection *webrtc.PeerConnection) (string, error)
transceiver.Direction() != webrtc.RTPTransceiverDirectionSendrecv) {
continue
}
// Pion has already matched the remote codecs and FMTP, preserving offer order.
// 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 {
name := strings.TrimPrefix(strings.ToLower(parameters.MimeType), "video/")
if _, supported := videoCodecs[name]; !supported {
continue
}
if name == "h264" && !forwardableH264(parameters.SDPFmtpLine) {
continue
}
if err := transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{parameters}); err != nil {
return "", err
}
@@ -434,11 +453,27 @@ func selectReceiverVideoCodec(connection *webrtc.PeerConnection) (string, error)
return "", errors.New("native media receiver offer has no supported video")
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
func forwardableH264(fmtp string) bool {
// Absent profile-level-id does not promise Constrained Baseline (RFC 6184).
var profile, packetization string
for _, parameter := range strings.Split(fmtp, ";") {
key, value, _ := strings.Cut(strings.TrimSpace(parameter), "=")
switch strings.ToLower(strings.TrimSpace(key)) {
case "profile-level-id":
profile = strings.TrimSpace(value)
case "packetization-mode":
packetization = strings.TrimSpace(value)
}
}
return false
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
}
}
@@ -0,0 +1,90 @@
package mediaedge
import (
"testing"
"time"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
// The current and candidate routes can have the same Native parent. Both Apps
// stay alive during preparation and rollback; replacing either Engine here
// would hide collisions in their shared UDP sockets.
func TestNativeReceiverOverlapKeepsCurrentMedia(t *testing.T) {
for _, outcome := range []string{"rollback", "commit"} {
t.Run(outcome, func(t *testing.T) {
host, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = host.Close() })
viewer, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = viewer.Close() })
source, err := host.NewSource("vp8", 2, 1, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
connect := func(id string) (*Edge, *Receiver, <-chan *rtp.Packet) {
t.Helper()
edge, err := host.NewEdge(source, EdgeOptions{ConnectionID: id})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = edge.Close() })
gathered := webrtc.GatheringCompletePromise(edge.connection)
if _, err = edge.CreateOffer(); err != nil {
t.Fatal(err)
}
waitSignal(t, gathered, "Native offer")
receiver, _, err := viewer.NewReceiver(ReceiverOptions{
Offer: *edge.connection.LocalDescription(), EdgeCapacity: 1,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
waitSignal(t, webrtc.GatheringCompletePromise(receiver.connection), "Native answer")
if err = edge.SetAnswer(*receiver.connection.LocalDescription()); err != nil {
t.Fatal(err)
}
waitConnected(t, edge.connection, id)
_, playback, _, err := connectedReceiverForSources(t, viewer, receiver.Source(), nil, id+"-playback")
if err != nil {
t.Fatal(err)
}
return edge, receiver, playback
}
currentEdge, current, currentPackets := connect("current")
if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, time.Second, time.Second/30); err != nil {
t.Fatal(err)
}
_ = waitPacket(t, currentPackets)
candidateEdge, candidate, candidatePackets := connect("candidate")
if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, 2*time.Second, time.Second/30); err != nil {
t.Fatal(err)
}
_ = waitPacket(t, currentPackets)
_ = waitPacket(t, candidatePackets)
retiredEdge, retired, surviving, survivingPackets := candidateEdge, candidate, current, currentPackets
if outcome == "commit" {
retiredEdge, retired, surviving, survivingPackets = currentEdge, current, candidate, candidatePackets
}
_ = retired.Close()
_ = retiredEdge.Close()
if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, 3*time.Second, time.Second/30); err != nil {
t.Fatal(err)
}
_ = waitPacket(t, survivingPackets)
if !surviving.active() {
t.Fatal("retiring an overlapping route closed the surviving receiver")
}
})
}
}
+134 -4
View File
@@ -1,17 +1,46 @@
package mediaedge
import (
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/rtp"
"github.com/pion/sdp/v3"
"github.com/pion/stun/v3"
"github.com/pion/webrtc/v4"
)
func TestOfferSendsCodecDistinguishesBundledAndRejectedMedia(t *testing.T) {
for _, test := range []struct {
name string
port int
attributes string
want bool
}{
{"ordinary", 9, "a=sendonly\r\n", true},
{"bundled", 0, "a=bundle-only\r\na=sendonly\r\n", true},
{"rejected", 0, "a=sendonly\r\n", false},
{"bundled-inactive", 0, "a=bundle-only\r\na=inactive\r\n", false},
{"bundled-receive-only", 0, "a=bundle-only\r\na=recvonly\r\n", false},
} {
t.Run(test.name, func(t *testing.T) {
offer := "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:0\r\na=rtpmap:96 VP8/90000\r\n" +
fmt.Sprintf("m=audio %d UDP/TLS/RTP/SAVPF 111\r\na=mid:1\r\na=rtpmap:111 opus/48000/2\r\n%s", test.port, test.attributes)
got, err := offerSendsCodec(offer, "audio", "opus")
if err != nil || got != test.want {
t.Fatalf("audio=%v, wanted %v: %v", got, test.want, err)
}
})
}
}
func TestReceiverAdvertisesLocalStereoPreference(t *testing.T) {
for _, remotePreference := range []string{"", ";stereo=0", ";stereo=1"} {
t.Run(remotePreference, func(t *testing.T) {
@@ -102,7 +131,7 @@ func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
upstream, err := engine.api.NewPeerConnection(webrtc.Configuration{})
upstream, err := testICESocket(t, engine).newPeerConnection()
if err != nil {
t.Fatal(err)
}
@@ -137,6 +166,96 @@ func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) {
}
}
func TestReceiverNegotiatesForwardableH264(t *testing.T) {
for _, test := range []struct {
name string
formats []string
want string
}{
{"browser-auto", []string{"42e01f", "vp8"}, "42e01f"},
{"safari-order", []string{"640c1f", "42e01f"}, "42e01f"},
{"main", []string{"4d001f"}, ""},
{"high", []string{"64001f"}, ""},
{"baseline", []string{"42001f"}, ""},
{"missing-profile", []string{""}, ""},
{"equivalent-baseline", []string{"42c01f"}, "42c01f"},
{"small-browser-output", []string{"42e00b"}, "42e00b"},
{"level-ceiling", []string{"42e033"}, "42e033"},
{"over-ceiling", []string{"42e034"}, ""},
{"invalid-level", []string{"42e035"}, ""},
{"invalid-constraints", []string{"42e11f"}, ""},
{"mode-zero", []string{"42e01f;packetization-mode=0"}, ""},
} {
t.Run(test.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() })
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 index, format := range test.formats {
codec := videoCodecs["h264"]
codec.SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1"
if format != "" {
codec.SDPFmtpLine += ";profile-level-id=" + format
}
if format == "vp8" {
codec = videoCodecs["vp8"]
}
codec.PayloadType = webrtc.PayloadType(96 + index)
preferences = append(preferences, codec)
}
if err = transceiver.SetCodecPreferences(preferences); err != nil {
t.Fatal(err)
}
offer, err := upstream.CreateOffer(nil)
if err != nil {
t.Fatal(err)
}
receiver, answer, err := engine.NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1})
if receiver != nil {
t.Cleanup(func() { _ = receiver.Close() })
}
if test.want == "" {
if err == nil {
t.Fatalf("unsupported format was admitted: %s", answer.SDP)
}
return
}
if err != nil {
t.Fatal(err)
}
if receiver.Codec() != "h264" || !strings.Contains(answer.SDP, "profile-level-id="+test.want) {
t.Fatalf("wanted %s, got %s: %s", test.want, receiver.Codec(), answer.SDP)
}
// A profile-only change is still a changed media shape. Reject it before
// mutating the current peer/source, so the owner can prepare a replacement.
previous := receiver.connection.RemoteDescription().SDP
changed := offer
changed.SDP = strings.ReplaceAll(changed.SDP, "profile-level-id="+test.want, "profile-level-id=4d001f")
_, reused, renegotiateErr := receiver.Renegotiate(changed, nil)
if renegotiateErr != nil || reused || receiver.connection.RemoteDescription().SDP != previous {
t.Fatalf("unsupported profile altered the current source: reused=%v, error=%v", reused, renegotiateErr)
}
source := receiver.Source()
_, reused, renegotiateErr = receiver.Renegotiate(offer, nil)
if renegotiateErr != nil || !reused || receiver.Source() != source {
t.Fatalf("compatible renegotiation replaced the source: reused=%v, error=%v", reused, renegotiateErr)
}
})
}
}
func TestReceiverForwardsEncodedVideoToANativeEdge(t *testing.T) {
for _, codec := range []string{"h264", "vp8"} {
t.Run(codec, func(t *testing.T) { testReceiverForwarding(t, codec) })
@@ -161,7 +280,7 @@ func testReceiverForwarding(t *testing.T, codec string) {
t.Fatal(err)
}
t.Cleanup(func() { _ = upstreamEngine.Close() })
upstream, err := upstreamEngine.api.NewPeerConnection(webrtc.Configuration{})
upstream, err := testICESocket(t, upstreamEngine).newPeerConnection()
if err != nil {
t.Fatal(err)
}
@@ -199,7 +318,12 @@ func testReceiverForwarding(t *testing.T, codec string) {
completed := make(chan struct{}, 4)
gatheringCurrent := make(chan func() bool, 4)
surveyCandidates := 0
servers := []webrtc.ICEServer{{URLs: []string{localSurveyServer(t)}}}
var bindingRequests atomic.Int32
server := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
bindingRequests.Add(1)
answerBinding(request, sender, listener, sender.Port)
})
servers := []webrtc.ICEServer{{URLs: []string{"stun:" + server.String()}}}
upstream.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
@@ -373,6 +497,7 @@ func testReceiverForwarding(t *testing.T, codec string) {
}
}()
source, audio := nativeReceiver.Source(), nativeReceiver.AudioSource()
socket := nativeReceiver.socket
for _, restart := range []bool{false, true} {
previousGathering := nativeReceiver.localCandidates
candidateMu.Lock()
@@ -424,8 +549,13 @@ func testReceiverForwarding(t *testing.T, codec string) {
if count != 2 {
t.Fatalf("STUN survey candidates=%d, want one per gathering", count)
}
if bindingRequests.Load() < 2 {
t.Fatal("receiver ICE restart reused a completed STUN observation")
}
} else if bindingRequests.Load() != 1 {
t.Fatal("ordinary SDP renegotiation restarted STUN discovery")
}
if nativeReceiver.Source() != source || nativeReceiver.AudioSource() != audio ||
if nativeReceiver.socket != socket || nativeReceiver.Source() != source || nativeReceiver.AudioSource() != audio ||
downstream.connection.ConnectionState() != webrtc.PeerConnectionStateConnected {
t.Fatal("renegotiation retired the source or its healthy downstream")
}
@@ -81,6 +81,8 @@ func TestRelayNetworkVP8Fixture(t *testing.T) {
sendNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"10.0.0.1"}})
check(err)
check(router.AddNet(sendNet))
engine.listenPacket = sendNet.ListenPacket
engine.bindAddress = "10.0.0.1:0"
engine.settings = webrtc.SettingEngine{}
engine.settings.SetNet(sendNet)
engine.settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
+106
View File
@@ -0,0 +1,106 @@
package mediaedge
import (
"context"
"fmt"
"net"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/pion/stun/v3"
)
// Only in-flight discovery is shared. Completed observations must not survive
// into a later ICE gathering: the socket can outlive its public NAT mapping.
func (socket *iceSocket) stunMapping(ctx context.Context, server *net.UDPAddr) (*stun.XORMappedAddress, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
result := socket.stunQueries.DoChan(server.String(), func() (any, error) {
// A retiring gathering must not cancel its successor's discovery. The
// connection owns the transaction; callers keep their own wait deadline.
queryContext, cancel := context.WithTimeout(socket.ctx, stunSurveyTimeout)
defer cancel()
return socket.querySTUN(queryContext, server)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-socket.ctx.Done():
return nil, socket.ctx.Err()
case outcome := <-result:
if outcome.Err != nil {
return nil, outcome.Err
}
return outcome.Val.(*stun.XORMappedAddress), nil
}
}
func (socket *iceSocket) querySTUN(ctx context.Context, server *net.UDPAddr) (*stun.XORMappedAddress, error) {
request := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
// Use an independent registration so late cleanup cannot retire the next
// query or an ICE connection. The UDP socket and healthy media stay intact.
key := fmt.Sprintf("stun-%x", request.TransactionID)
connection, err := socket.mux.GetConn(key, socket.mux.LocalAddr())
if err != nil {
return nil, err
}
defer socket.mux.RemoveConnByUfrag(key)
defer connection.Close()
client, err := stun.NewClient(&stunConnection{PacketConn: connection, server: server},
stun.WithLoggerFactory(diagnostics.PionLoggerFactory()))
if err != nil {
return nil, err
}
defer client.Close()
type response struct {
address *stun.XORMappedAddress
err error
}
result := make(chan response, 1)
err = client.Start(request, func(event stun.Event) {
if event.Error != nil {
result <- response{err: event.Error}
return
}
if event.Message.Type != stun.BindingSuccess {
result <- response{err: fmt.Errorf("STUN binding returned %s", event.Message.Type)}
return
}
var address stun.XORMappedAddress
err := address.GetFrom(event.Message)
result <- response{address: &address, err: err}
})
if err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case value := <-result:
return value.address, value.err
}
}
// Pion's STUN client owns transaction matching and retransmission. This view
// only addresses its packets on the existing media mux.
type stunConnection struct {
net.PacketConn
server *net.UDPAddr
}
func (connection *stunConnection) Read(buffer []byte) (int, error) {
for {
n, sender, err := connection.ReadFrom(buffer)
if err != nil {
return n, err
}
if address, ok := sender.(*net.UDPAddr); ok && address.Port == connection.server.Port &&
address.IP.Equal(connection.server.IP) {
return n, nil
}
}
}
func (connection *stunConnection) Write(buffer []byte) (int, error) {
return connection.WriteTo(buffer, connection.server)
}
+220
View File
@@ -0,0 +1,220 @@
package mediaedge
import (
"context"
"net"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/pion/rtp"
"github.com/pion/stun/v3"
"github.com/pion/webrtc/v4"
)
func bindingServer(t *testing.T, respond func(*stun.Message, *net.UDPAddr, *net.UDPConn)) *net.UDPAddr {
t.Helper()
listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
go func() {
buffer := make([]byte, 1500)
for {
n, sender, err := listener.ReadFromUDP(buffer)
if err != nil {
return
}
message := &stun.Message{Raw: append([]byte(nil), buffer[:n]...)}
if message.Decode() == nil && message.Type == stun.BindingRequest {
respond(message, sender, listener)
}
}
}()
return listener.LocalAddr().(*net.UDPAddr)
}
func answerBinding(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn, port int) {
response := stun.MustBuild(stun.NewTransactionIDSetter(request.TransactionID), stun.BindingSuccess,
&stun.XORMappedAddress{IP: sender.IP, Port: port})
_, _ = listener.WriteToUDP(response.Raw, sender)
}
func TestNativeGatheringRefreshesMappingWithoutInterruptingSibling(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() })
source, err := engine.NewSource("vp8", 2, 1, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = source.Close() })
sibling, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "healthy"})
if err != nil {
t.Fatal(err)
}
receiver := newReceiverWithAudio(t, false, "127.0.0.1:0")
t.Cleanup(func() { _ = receiver.Close() })
packets := make(chan *rtp.Packet, 16)
receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
for {
packet, _, err := track.ReadRTP()
if err != nil {
return
}
packets <- packet
}
})
connectEdgeToReceiver(t, sibling, receiver)
var mappedPort, requests atomic.Int32
var observedLocalPort atomic.Int32
mappedPort.Store(40000)
server := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
requests.Add(1)
observedLocalPort.Store(int32(sender.Port))
answerBinding(request, sender, listener, int(mappedPort.Load()))
})
for attempt := range 3 {
wantPort := 40000 + attempt
mappedPort.Store(int32(wantPort))
candidates := make(chan *webrtc.ICECandidateInit, 16)
edge, err := engine.NewEdge(source, EdgeOptions{
ConnectionID: "recover-" + strconv.Itoa(attempt),
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:" + server.String()}}},
Events: EdgeEvents{LocalCandidate: func(candidate *webrtc.ICECandidateInit) { candidates <- candidate }},
})
if err != nil {
t.Fatal(err)
}
if _, err = edge.CreateOffer(); err != nil {
t.Fatal(err)
}
found := false
for !found {
select {
case candidate := <-candidates:
if candidate == nil {
t.Fatal("gathering ended without a fresh STUN observation")
}
if strings.HasPrefix(candidate.Candidate, "candidate:ns") {
fields := strings.Fields(candidate.Candidate)
if observedLocalPort.Load() != int32(edge.socket.localPort) || fields[len(fields)-1] != strconv.Itoa(edge.socket.localPort) {
t.Fatal("STUN and candidate discovery left the edge's media socket")
}
if fields[5] != strconv.Itoa(wantPort) {
t.Fatalf("attempt %d reused mapping %s; want %d", attempt, fields[5], wantPort)
}
found = true
}
case <-time.After(2 * time.Second):
t.Fatal("fresh mapping was not gathered")
}
}
_ = edge.Close()
if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, time.Duration(attempt+1)*time.Second, time.Second/30); err != nil {
t.Fatal(err)
}
_ = waitPacket(t, packets)
if sibling.State() != webrtc.PeerConnectionStateConnected {
t.Fatal("refreshing a mapping retired a healthy media connection")
}
}
if requests.Load() < 3 {
t.Fatal("later gatherings did not query STUN again")
}
}
func TestSurveyRetriesLostBindingAndIgnoresUnmatchedResponse(t *testing.T) {
engine, err := NewEngine(EngineOptions{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
var requests atomic.Int32
server := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
if requests.Add(1) == 1 {
// A delayed response from another transaction must not satisfy this
// observation. Lose the real response to exercise stock retransmission.
answerBinding(stun.MustBuild(stun.TransactionID, stun.BindingRequest), sender, listener, 40000)
return
}
answerBinding(request, sender, listener, 40001)
})
observations := 0
socket.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:" + server.String()}}}, func(mapped mappedAddress) {
observations++
if mapped.port != 40001 {
t.Errorf("accepted an unmatched STUN transaction: %+v", mapped)
}
})
if requests.Load() < 2 || observations != 1 {
t.Fatalf("a lost Binding response prevented discovery: requests=%d observations=%d", requests.Load(), observations)
}
}
func TestConcurrentSurveyCancellationAndEngineRetirement(t *testing.T) {
engine, err := NewEngine(EngineOptions{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
socket := testICESocket(t, engine)
var answer atomic.Bool
requested := make(chan struct{}, 16)
server := bindingServer(t, func(request *stun.Message, sender *net.UDPAddr, listener *net.UDPConn) {
select {
case requested <- struct{}{}:
default:
}
if answer.Load() {
answerBinding(request, sender, listener, 40001)
}
})
servers := []webrtc.ICEServer{{URLs: []string{"stun:" + server.String()}}}
retiringContext, cancel := context.WithCancel(t.Context())
retired := make(chan struct{})
go func() {
defer close(retired)
socket.surveySTUN(retiringContext, servers, func(mappedAddress) { t.Error("retired survey emitted a candidate") })
}()
waitSignal(t, requested, "initial Binding request")
const siblings = 3
results := make(chan mappedAddress, siblings)
for range siblings {
go socket.surveySTUN(t.Context(), servers, func(mapped mappedAddress) { results <- mapped })
}
cancel()
waitSignal(t, retired, "retired gathering")
answer.Store(true)
for range siblings {
select {
case value := <-results:
if value.port != 40001 {
t.Fatal("concurrent gathering received a stale observation")
}
case <-time.After(2 * time.Second):
t.Fatal("retiring a gathering canceled healthy siblings")
}
}
// A completed transaction must not keep a cached result; the next blocked
// query belongs to the same Engine and must retire with it.
answer.Store(false)
for len(requested) > 0 {
<-requested
}
stopped := make(chan struct{})
go func() {
defer close(stopped)
socket.surveySTUN(t.Context(), servers, func(mappedAddress) { t.Error("retained a completed mapping") })
}()
waitSignal(t, requested, "new Binding request after success")
_ = engine.Close()
waitSignal(t, stopped, "Engine shutdown during discovery")
}
+6 -2
View File
@@ -1215,8 +1215,12 @@ func (state CaptureState) applyBackend(options *nativecapture.VideoOptions) {
}
func validH264ProfileLevelID(value string) bool {
switch value {
case "42c01e", "42c01f", "42c020", "42c028", "42c029", "42c02a", "42c032", "42c033":
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
+16
View File
@@ -429,6 +429,22 @@ func TestCaptureStateRejectsUnknownOrUnattributedState(t *testing.T) {
}
}
func TestCaptureStateValidatesH264ToolsRatherThanConstraintSpelling(t *testing.T) {
for _, profile := range []string{"42c01f", "42e01f", "42E033", "4d801f", "58c02a"} {
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"} {
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)
}
}
}
func TestCaptureStateUsesOriginalSlotAndBoundsPhysicalGroups(t *testing.T) {
state, err := decodeCaptureState(captureStatePayload(t,
`{"state":"active","hardwareOnly":false,"codec":"vp8","width":1280,"height":720,"fps":30}`))
+5 -3
View File
@@ -15,7 +15,7 @@ const (
// five-second direct-connection head start.
attemptTimeout = 3 * time.Second
deleteTimeout = 2 * time.Second
// RFC 6886 recommends a two-hour NAT-PMP lease; later edges renew it at
// RFC 6886 recommends a two-hour NAT-PMP lease; later gatherings renew it at
// half-life without adding a background poller.
leaseDuration = 2 * time.Hour
)
@@ -33,6 +33,7 @@ var discoverGateway = func(ctx context.Context) (gateway, error) {
// functional when no mapping service is available.
type Mapping struct {
localPort int
ctx context.Context
cancel context.CancelFunc
ready chan struct{}
@@ -50,6 +51,7 @@ func Start(localPort int) *Mapping {
ctx, cancel := context.WithCancel(context.Background())
mapping := &Mapping{
localPort: localPort,
ctx: ctx,
cancel: cancel,
ready: make(chan struct{}),
}
@@ -61,14 +63,14 @@ func (mapping *Mapping) Prepare() int {
<-mapping.ready
mapping.mu.Lock()
defer mapping.mu.Unlock()
if mapping.closed || mapping.gateway == nil ||
if mapping.closed || mapping.ctx.Err() != nil || mapping.gateway == nil ||
(mapping.attempted && !mapping.mapped) {
return 0
}
if mapping.mapped && time.Now().Before(mapping.renewAfter) {
return mapping.externalPort
}
ctx, cancel := context.WithTimeout(context.Background(), attemptTimeout)
ctx, cancel := context.WithTimeout(mapping.ctx, attemptTimeout)
defer cancel()
mapping.mapPortLocked(ctx)
if !mapping.mapped {
+43 -2
View File
@@ -88,7 +88,7 @@ func TestMappingAbsenceLeavesICEAvailable(t *testing.T) {
mapping.Close()
}
func TestMappingFailureIsNotRetriedForEveryEdge(t *testing.T) {
func TestMappingFailureIsNotRetriedWithinTheConnection(t *testing.T) {
original := discoverGateway
t.Cleanup(func() { discoverGateway = original })
fake := &fakeGateway{addErr: errors.New("mapping rejected")}
@@ -191,7 +191,48 @@ func TestRenewalFailureKeepsTheOriginalMappingOwnedUntilClose(t *testing.T) {
}
}
func TestMappingDiscoveryStopsWithTheShare(t *testing.T) {
type cancelableGateway struct {
*fakeGateway
started chan struct{}
}
func (gateway *cancelableGateway) AddPortMapping(ctx context.Context, _ string, _ int, _ string, _ time.Duration) (int, error) {
close(gateway.started)
<-ctx.Done()
return 0, ctx.Err()
}
func TestMappingCloseCancelsPendingCreation(t *testing.T) {
original := discoverGateway
t.Cleanup(func() { discoverGateway = original })
fake := &cancelableGateway{fakeGateway: &fakeGateway{}, started: make(chan struct{})}
discoverGateway = func(context.Context) (gateway, error) { return fake, nil }
mapping := Start(43210)
prepared := make(chan int, 1)
go func() { prepared <- mapping.Prepare() }()
<-fake.started
done := make(chan struct{})
go func() {
mapping.Close()
close(done)
}()
t.Cleanup(func() { <-done })
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("mapping close did not cancel creation")
}
if port := <-prepared; port != 0 {
t.Fatalf("retired mapping returned port %d", port)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if len(fake.deleted) != 1 {
t.Fatalf("cleanup calls = %v, want the canceled attempt released", fake.deleted)
}
}
func TestMappingDiscoveryStopsWithTheConnection(t *testing.T) {
original := discoverGateway
t.Cleanup(func() { discoverGateway = original })
discoverGateway = func(ctx context.Context) (gateway, error) {
+61 -20
View File
@@ -75,6 +75,7 @@ func TestMain(tests *testing.M) {
time.Sleep(time.Millisecond)
}
}
fmt.Fprintln(os.Stderr, "fixture waiting")
for {
time.Sleep(time.Hour)
}
@@ -126,20 +127,35 @@ func TestStartupRetriesOnlyRetiredPreReadyChildren(t *testing.T) {
}
}
// Child startup has no sub-second timing guarantee on Windows. Trigger
// cancellation at the observed lifecycle boundary, not at an assumed launch time.
type startupCancelLog struct {
slog.Handler
key, value string
cancel context.CancelFunc
}
func (handler startupCancelLog) Handle(ctx context.Context, record slog.Record) error {
record.Attrs(func(attr slog.Attr) bool {
if attr.Key == handler.key && attr.Value.String() == handler.value {
handler.cancel()
}
return true
})
return handler.Handler.Handle(ctx, record)
}
func TestStartupRetriesShareCancellationAndDeadline(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, sample := range []struct {
name string
timeout time.Duration
attempts int
cancelOnly bool
name, key, value string
attempts int
}{
{"during retry delay", 500 * time.Millisecond, 1, false},
{"during second child", 2 * time.Second, 2, false},
{"clean cancellation during retry delay", 500 * time.Millisecond, 1, true},
{"during retry delay", "event", "startup-retry", 1},
{"during second child", "message", "fixture waiting", 2},
} {
t.Run(sample.name, func(t *testing.T) {
record := filepath.Join(t.TempDir(), "attempts")
@@ -147,25 +163,50 @@ func TestStartupRetriesShareCancellationAndDeadline(t *testing.T) {
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", "1")
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", "7")
ctx, cancel := context.WithTimeout(t.Context(), sample.timeout)
bound, stop := context.WithTimeout(t.Context(), 10*time.Second)
defer stop()
ctx, cancel := context.WithCancel(bound)
defer cancel()
if sample.cancelOnly {
ctx, cancel = context.WithCancel(t.Context())
defer cancel()
defer time.AfterFunc(sample.timeout, cancel).Stop()
}
started := time.Now()
previous := slog.Default()
slog.SetDefault(slog.New(startupCancelLog{
Handler: slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}),
key: sample.key, value: sample.value, cancel: cancel,
}))
defer slog.SetDefault(previous)
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
if process != nil || (!sample.cancelOnly && !errors.Is(err, context.DeadlineExceeded)) ||
(sample.cancelOnly && err != context.Canceled) {
t.Fatalf("startup deadline: %v, %v", process, err)
}
if time.Since(started) > sample.timeout+2*time.Second {
t.Fatal("retry renewed the startup deadline")
if process != nil || err != context.Canceled {
t.Fatalf("startup cancellation: %v, %v", process, err)
}
assertRetiredAttempts(t, record, sample.attempts)
})
}
t.Run("shared deadline", func(t *testing.T) {
record := filepath.Join(t.TempDir(), "attempts")
t.Setenv("PIIK_TUNNEL_FIXTURE", "starting")
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", "1")
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", "7")
const timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
started := time.Now()
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
if process != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("startup deadline: %v, %v", process, err)
}
if time.Since(started) > timeout+2*time.Second {
t.Fatal("retry renewed the startup deadline")
}
// A slow launch can consume the deadline before reaching the retry or
// the second child. Check cleanup for the children that actually ran.
payload, err := os.ReadFile(record)
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if len(payload) > 0 {
assertRetiredAttempts(t, record, len(strings.Split(strings.TrimSpace(string(payload)), "\n")))
}
})
}
func assertRetiredAttempts(t *testing.T, record string, count int) {
+19
View File
@@ -0,0 +1,19 @@
package encoded
import "encoding/hex"
// H264ConstrainedBaselineLevel recognizes the equivalent coding-tool subsets
// in RFC 6184 Table 5. Callers own their permitted level range.
func H264ConstrainedBaselineLevel(value string) (byte, bool) {
if len(value) != 6 {
return 0, false
}
profile, err := hex.DecodeString(value)
if err != nil {
return 0, false
}
constrainedBaseline := (profile[0] == 0x42 && profile[1]&0x4f == 0x40) ||
(profile[0] == 0x4d && profile[1]&0x8f == 0x80) ||
(profile[0] == 0x58 && profile[1]&0xcf == 0xc0)
return profile[2], constrainedBaseline
}