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:
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user