owpengram-server/internal/sfu/pion.go

735 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sfu
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"sync"
"time"
"github.com/pion/datachannel"
"github.com/pion/dtls/v3"
dtlsnet "github.com/pion/dtls/v3/pkg/net"
"github.com/pion/ice/v4"
"github.com/pion/logging"
"github.com/pion/rtcp"
"github.com/pion/sctp"
"github.com/pion/srtp/v3"
"go.uber.org/zap"
)
// PionConfig 是内嵌 SFU 的运行参数。
type PionConfig struct {
// UDPPort 是单 UDP 监听端口pion ICE UDPMux全部 endpoint 复用)。
UDPPort int
// AdvertiseIP 是写进下行 candidate 的客户端可达地址。⚠ 127.0.0.1 会让
// 真机 ICE 永远连不上且无任何 RPC 错误(纯媒体面静默失败)。
AdvertiseIP string
Logger *zap.Logger
// Touch 是媒体面活性回报钩子:对仍存活(近期 SRTP 收包/ICE 连通)的
// endpoint 周期性刷新 group_call_participants.last_check_date使 sweeper 的
// 单一水位同时承载「心跳 媒体活性」P0-2 双过期判据的实现方式)。
Touch func(callID, userID int64)
// LivenessInterval 是活性回报周期(默认 15s远小于 sweeper 的 45s TTL
LivenessInterval time.Duration
// ActivityWindow 判定 endpoint 媒体面存活的最近收包窗口(默认 25s
ActivityWindow time.Duration
}
// pionSFU 是 Service 的 pion 实现full-ICE CONTROLLING + DTLS 主动握手client
// 角色,对应下行 setup:"active"+ SRTP 房间内原样转发(不重写 SSRC、保留
// audio-level 等 RTP 扩展speaking 指示由客户端渲染)。
type pionSFU struct {
cfg PionConfig
log *zap.Logger
udpConn net.PacketConn
mux *ice.UDPMuxDefault
cert tls.Certificate
fingerprint string
mu sync.Mutex
rooms map[int64]*room
cancel context.CancelFunc
}
type epKey struct {
userID int64
kind EndpointKind
}
type room struct {
callID int64
endpoints map[epKey]*endpoint
}
// mediaPlan 是一个发布 endpoint 的 ssrc 转发计划Join 时从 ssrc-groups 算定):
// 订阅端只为 SIM[0](最低层)建解码 sink——只转发该层与其 FID RTX 伙伴,
// 其余 simulcast 层在 SFU 侧丢弃(客户端反正会丢,转发纯浪费下行)。
type mediaPlan struct {
audioSSRC uint32
forward map[uint32]bool // 转发的视频 ssrcSIM[0] 层 + 其 RTX
drop map[uint32]bool // 其余已声明视频 ssrc高层及其 RTX
}
func buildMediaPlan(offer ClientOffer) mediaPlan {
plan := mediaPlan{
audioSSRC: offer.AudioSSRC,
forward: make(map[uint32]bool),
drop: make(map[uint32]bool),
}
var primary uint32
for _, g := range offer.SsrcGroups {
if g.Semantics == "SIM" && len(g.Sources) > 0 {
primary = g.Sources[0]
break
}
}
// 单层发布conference 模式)没有 SIM 组:唯一组的第一个 ssrc 即主流。
if primary == 0 && len(offer.SsrcGroups) == 1 && len(offer.SsrcGroups[0].Sources) > 0 {
primary = offer.SsrcGroups[0].Sources[0]
}
for _, g := range offer.SsrcGroups {
for i, ssrc := range g.Sources {
keep := ssrc == primary ||
(g.Semantics == "FID" && i == 1 && len(g.Sources) == 2 && g.Sources[0] == primary)
if keep {
plan.forward[ssrc] = true
} else if !plan.forward[ssrc] {
plan.drop[ssrc] = true
}
}
}
for ssrc := range plan.forward {
delete(plan.drop, ssrc)
}
return plan
}
// shouldForward 报告该 ssrc 的流是否进入扇出true或在 SFU 终结false
// 未声明的 ssrc 一律转发:音频、未来的探测流——订阅端对无 sink 的包安全丢弃。
func (p mediaPlan) shouldForward(ssrc uint32) bool {
return !p.drop[ssrc]
}
type endpoint struct {
callID int64
userID int64
kind EndpointKind
plan mediaPlan
sfu *pionSFU
agent *ice.Agent
demux *demuxer
closed chan struct{}
mu sync.Mutex
writeStream *srtp.WriteStreamSRTP
rtcpWriter *srtp.WriteStreamSRTCP
lastActivity time.Time
connected bool
closeOnce sync.Once
}
// NewPion 启动内嵌 SFU绑定 UDP 端口、生成进程级 DTLS 证书。
func NewPion(cfg PionConfig) (Service, error) {
if cfg.UDPPort <= 0 {
return nil, fmt.Errorf("sfu: invalid udp port %d", cfg.UDPPort)
}
if cfg.AdvertiseIP == "" {
cfg.AdvertiseIP = "127.0.0.1"
}
if cfg.Logger == nil {
cfg.Logger = zap.NewNop()
}
if cfg.LivenessInterval <= 0 {
cfg.LivenessInterval = 15 * time.Second
}
if cfg.ActivityWindow <= 0 {
cfg.ActivityWindow = 25 * time.Second
}
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: cfg.UDPPort})
if err != nil {
return nil, fmt.Errorf("sfu: listen udp %d: %w", cfg.UDPPort, err)
}
cert, fingerprint, err := newDTLSCertificate()
if err != nil {
_ = udpConn.Close()
return nil, err
}
s := &pionSFU{
cfg: cfg,
log: cfg.Logger,
udpConn: udpConn,
mux: ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: udpConn}),
cert: cert,
fingerprint: fingerprint,
rooms: make(map[int64]*room),
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
go s.livenessLoop(ctx)
s.log.Info("sfu listening",
zap.Int("udp_port", cfg.UDPPort),
zap.String("advertise_ip", cfg.AdvertiseIP))
if cfg.AdvertiseIP == "127.0.0.1" {
s.log.Warn("TELESRV_SFU_ADVERTISE_IP 为 127.0.0.1:真机 ICE 将无法连接(纯媒体面静默失败),多设备联调必须设为宿主机 LAN IP")
}
return s, nil
}
func (s *pionSFU) Enabled() bool { return true }
// Join 为参与者建立媒体 endpoint本端生成独立 ufrag/pwdICE CONTROLLING 等待
// 客户端 Binding Request上行 JSON 无 candidates对端地址靠 peer-reflexive 学得),
// 连通后以 DTLS client 主动握手并建 SRTP 会话。
func (s *pionSFU) Join(ctx context.Context, callID, userID int64, kind EndpointKind, offer ClientOffer) (ServerAnswer, error) {
if offer.Ufrag == "" || offer.Pwd == "" {
return ServerAnswer{}, fmt.Errorf("sfu: empty remote ice credentials")
}
localUfrag, err := randomICEString(8)
if err != nil {
return ServerAnswer{}, err
}
localPwd, err := randomICEString(24)
if err != nil {
return ServerAnswer{}, err
}
agent, err := ice.NewAgent(&ice.AgentConfig{
NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4},
CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost},
UDPMux: s.mux,
LocalUfrag: localUfrag,
LocalPwd: localPwd,
IncludeLoopback: true,
LoggerFactory: &pionLoggerFactory{log: s.log.Named("ice")},
})
if err != nil {
return ServerAnswer{}, fmt.Errorf("sfu: new ice agent: %w", err)
}
// pion 要求注册 OnCandidate 才能收集;候选地址由我们直接以
// AdvertiseIP:UDPPort 写进下行 JSON回调本身无需消费。
if err := agent.OnCandidate(func(ice.Candidate) {}); err != nil {
_ = agent.Close()
return ServerAnswer{}, fmt.Errorf("sfu: on candidate: %w", err)
}
if err := agent.GatherCandidates(); err != nil {
_ = agent.Close()
return ServerAnswer{}, fmt.Errorf("sfu: gather candidates: %w", err)
}
ep := &endpoint{
callID: callID,
userID: userID,
kind: kind,
plan: buildMediaPlan(offer),
sfu: s,
agent: agent,
closed: make(chan struct{}),
}
s.attachEndpoint(ep)
go ep.run(offer)
return ServerAnswer{
Ufrag: localUfrag,
Pwd: localPwd,
FingerprintSHA256: s.fingerprint,
Candidates: []Candidate{{
IP: s.cfg.AdvertiseIP,
Port: s.cfg.UDPPort,
Protocol: "udp",
Type: "host",
}},
}, nil
}
func (s *pionSFU) attachEndpoint(ep *endpoint) {
key := epKey{userID: ep.userID, kind: ep.kind}
s.mu.Lock()
rm, ok := s.rooms[ep.callID]
if !ok {
rm = &room{callID: ep.callID, endpoints: make(map[epKey]*endpoint)}
s.rooms[ep.callID] = rm
}
old := rm.endpoints[key]
rm.endpoints[key] = ep
var oldPresentation *endpoint
if ep.kind == EndpointMain {
// 主连接 rejoin旧 presentation 登记一并作废(信令侧同样清 presentation_json
// 客户端随后重发 joinGroupCallPresentation
pk := epKey{userID: ep.userID, kind: EndpointPresentation}
oldPresentation = rm.endpoints[pk]
delete(rm.endpoints, pk)
}
s.mu.Unlock()
if old != nil {
old.close() // rejoin替换旧 endpoint
}
if oldPresentation != nil {
oldPresentation.close()
}
}
func (s *pionSFU) Leave(_ context.Context, callID, userID int64, kind EndpointKind) error {
var victims []*endpoint
s.mu.Lock()
if rm, ok := s.rooms[callID]; ok {
keys := []epKey{{userID: userID, kind: kind}}
if kind == EndpointMain {
// 整体离会从不补发 leaveGroupCallPresentation主 endpoint 离开联动拆屏幕。
keys = append(keys, epKey{userID: userID, kind: EndpointPresentation})
}
for _, key := range keys {
if ep := rm.endpoints[key]; ep != nil {
victims = append(victims, ep)
delete(rm.endpoints, key)
}
}
if len(rm.endpoints) == 0 {
delete(s.rooms, callID)
}
}
s.mu.Unlock()
for _, ep := range victims {
ep.close()
}
return nil
}
func (s *pionSFU) CloseRoom(_ context.Context, callID int64) error {
s.mu.Lock()
rm := s.rooms[callID]
delete(s.rooms, callID)
s.mu.Unlock()
if rm != nil {
for _, ep := range rm.endpoints {
ep.close()
}
}
return nil
}
func (s *pionSFU) AliveUserIDs(callID int64) []int64 {
cutoff := time.Now().Add(-s.cfg.ActivityWindow)
s.mu.Lock()
defer s.mu.Unlock()
rm, ok := s.rooms[callID]
if !ok {
return nil
}
seen := make(map[int64]bool)
var out []int64
for key, ep := range rm.endpoints {
// 任一连接(主/屏幕)存活即视为该参与者媒体面存活。
if !seen[key.userID] && ep.aliveSince(cutoff) {
seen[key.userID] = true
out = append(out, key.userID)
}
}
return out
}
// livenessLoop 周期性把媒体面存活回报给信令侧(刷新保活水位)。
func (s *pionSFU) livenessLoop(ctx context.Context) {
ticker := time.NewTicker(s.cfg.LivenessInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if s.cfg.Touch == nil {
continue
}
cutoff := time.Now().Add(-s.cfg.ActivityWindow)
type key struct{ callID, userID int64 }
seen := make(map[key]bool)
var alive []key
s.mu.Lock()
for callID, rm := range s.rooms {
for ek, ep := range rm.endpoints {
k := key{callID, ek.userID}
if !seen[k] && ep.aliveSince(cutoff) {
seen[k] = true
alive = append(alive, k)
}
}
}
s.mu.Unlock()
for _, k := range alive {
s.cfg.Touch(k.callID, k.userID)
}
}
}
}
// forwardRTP 把一条解密后的 RTP 包原样转发给房间内其他参与者的**主** endpoint
// (不重写 SSRC扩展头随包透传——audio-level 即 speaking 指示数据源;
// 观看屏幕共享也走观看者的主连接presentation 连接不收任何远端流)。
func (s *pionSFU) forwardRTP(from *endpoint, packet []byte) {
for _, ep := range s.mainTargets(from) {
ep.writeRTP(packet)
}
}
// mainTargets 返回房间内除 from 所属参与者外的全部主 endpoint。
func (s *pionSFU) mainTargets(from *endpoint) []*endpoint {
s.mu.Lock()
defer s.mu.Unlock()
rm, ok := s.rooms[from.callID]
if !ok {
return nil
}
targets := make([]*endpoint, 0, len(rm.endpoints))
for key, ep := range rm.endpoints {
if key.userID != from.userID && key.kind == EndpointMain {
targets = append(targets, ep)
}
}
return targets
}
// publisherBySSRC 找到房间内声明了该 ssrc音频/任一视频层/RTX的发布 endpoint
// 供 RTCP 反馈PLI/NACK按 media ssrc 路由回发布端。房间规模小,直接遍历。
func (s *pionSFU) publisherBySSRC(callID int64, ssrc uint32) *endpoint {
s.mu.Lock()
defer s.mu.Unlock()
rm, ok := s.rooms[callID]
if !ok {
return nil
}
for _, ep := range rm.endpoints {
if ep.plan.audioSSRC == ssrc || ep.plan.forward[ssrc] || ep.plan.drop[ssrc] {
return ep
}
}
return nil
}
// ---- endpoint ----
func (ep *endpoint) run(offer ClientOffer) {
s := ep.sfu
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// CONTROLLING主动发起连通性检查对端候选靠带 ufrag/pwd 认证的
// Binding Request 学得peer-reflexive
conn, err := ep.agent.Dial(ctx, offer.Ufrag, offer.Pwd)
if err != nil {
s.log.Debug("sfu ice dial", zap.Int64("call_id", ep.callID), zap.Int64("user_id", ep.userID), zap.Error(err))
ep.close()
return
}
ep.markActivity()
ep.demux = newDemuxer(conn)
dtlsConfig := &dtls.Config{
Certificates: []tls.Certificate{s.cert},
InsecureSkipVerify: true, // 身份校验走指纹(信令面 commit非 CA 链
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{
dtls.SRTP_AEAD_AES_128_GCM,
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
},
}
dtlsRaw := ep.demux.dtlsConn()
dtlsConn, err := dtls.Client(dtlsnet.PacketConnFromConn(dtlsRaw), dtlsRaw.RemoteAddr(), dtlsConfig)
if err != nil {
s.log.Debug("sfu dtls client", zap.Int64("user_id", ep.userID), zap.Error(err))
ep.close()
return
}
// pion/dtls v3 构造器是惰性握手(首次读写才握手):必须显式 HandshakeContext
// 完成并设硬上限,否则 ConnectionState 拿不到状态、停滞的握手会挂死 goroutine。
hsCtx, hsCancel := context.WithTimeout(context.Background(), 20*time.Second)
err = dtlsConn.HandshakeContext(hsCtx)
hsCancel()
if err != nil {
s.log.Debug("sfu dtls handshake", zap.Int64("user_id", ep.userID), zap.Error(err))
ep.close()
return
}
// 指纹核验DTLS 证书必须与 join JSON 承诺的 sha-256 指纹一致。
state, ok := dtlsConn.ConnectionState()
if !ok || len(state.PeerCertificates) == 0 {
ep.close()
return
}
gotFP, err := certificateFingerprint(state.PeerCertificates[0])
if err != nil || normalizeFingerprint(gotFP) != normalizeFingerprint(offer.FingerprintSHA256) {
s.log.Warn("sfu dtls fingerprint mismatch", zap.Int64("user_id", ep.userID))
ep.close()
return
}
profile, ok := dtlsConn.SelectedSRTPProtectionProfile()
if !ok {
ep.close()
return
}
srtpConfig := &srtp.Config{}
switch profile {
case dtls.SRTP_AEAD_AES_128_GCM:
srtpConfig.Profile = srtp.ProtectionProfileAeadAes128Gcm
case dtls.SRTP_AES128_CM_HMAC_SHA1_80:
srtpConfig.Profile = srtp.ProtectionProfileAes128CmHmacSha1_80
default:
ep.close()
return
}
// SFU 是 DTLS clientisClient=true 决定 SRTP 读写密钥方向。
if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, true); err != nil {
s.log.Debug("sfu srtp keys", zap.Error(err))
ep.close()
return
}
session, err := srtp.NewSessionSRTP(ep.demux.srtpConn(), srtpConfig)
if err != nil {
s.log.Debug("sfu srtp session", zap.Error(err))
ep.close()
return
}
writeStream, err := session.OpenWriteStream()
if err != nil {
ep.close()
return
}
// RTCP 升级为 SRTCP session同一份 srtpConfig 复用):读侧做 PLI/NACK 路由,
// 写侧供把反馈转投给发布端。注意:建了 SessionSRTCP 后绝不能再裸读
// srtcpConn双读互偷包
rtcpSession, err := srtp.NewSessionSRTCP(ep.demux.srtcpConn(), srtpConfig)
if err != nil {
s.log.Debug("sfu srtcp session", zap.Error(err))
ep.close()
return
}
rtcpWriter, err := rtcpSession.OpenWriteStream()
if err != nil {
ep.close()
return
}
ep.mu.Lock()
ep.writeStream = writeStream
ep.rtcpWriter = rtcpWriter
ep.connected = true
ep.mu.Unlock()
ep.markActivity()
s.log.Info("sfu endpoint connected",
zap.Int64("call_id", ep.callID),
zap.Int64("user_id", ep.userID),
zap.Int("kind", int(ep.kind)))
go ep.serveRTCP(rtcpSession)
// SCTP 数据通道骑在同一条 DTLS 连接上(握手后的 application data 即 SCTP 包)。
go ep.serveData(dtlsConn)
// 入站 SRTP按 SSRC 接收流。转发/终结决策在流粒度一次性做出
//AcceptStream 即按 SSRC 分流selective forwarding 零每包开销)。
for {
stream, ssrc, err := session.AcceptStream()
if err != nil {
ep.close()
return
}
if ep.plan.shouldForward(ssrc) {
go ep.readStream(stream, ssrc)
} else {
// 非转发层(高层 simulcast 及其 RTX必须持续排空防 buffer 堆积。
go ep.drainStream(stream)
}
}
}
func (ep *endpoint) readStream(stream *srtp.ReadStreamSRTP, ssrc uint32) {
buf := make([]byte, 1500)
for {
n, err := stream.Read(buf)
if err != nil {
return
}
ep.markActivity()
packet := make([]byte, n)
copy(packet, buf[:n])
ep.sfu.forwardRTP(ep, packet)
}
}
func (ep *endpoint) drainStream(stream *srtp.ReadStreamSRTP) {
buf := make([]byte, 1500)
for {
if _, err := stream.Read(buf); err != nil {
return
}
ep.markActivity()
}
}
// serveRTCP 消费本 endpoint 的入站 RTCP 并路由:
// - PLI / FIR / NACK订阅端反馈→ 按 media ssrc 找到发布 endpoint 转投——
// 恒等转发下订阅端反馈里的 ssrc 就是发布端的原始 ssrc无需改写
// 发布端自带 RTX 重传与 300ms keyframe 限流;
// - SR发布端报告→ 转发给其他主 endpointA/V 同步的 NTP↔RTP 映射来源);
// - RR / REMB / transport-cc丢弃v1 不做带宽自适应layer0 所需码率
// 远低于发布端 400kbps 起始值,不会饿死)。
func (ep *endpoint) serveRTCP(session *srtp.SessionSRTCP) {
for {
stream, _, err := session.AcceptStream()
if err != nil {
return
}
go func() {
buf := make([]byte, 1500)
for {
n, err := stream.Read(buf)
if err != nil {
return
}
ep.markActivity()
pkts, err := rtcp.Unmarshal(buf[:n])
if err != nil {
continue
}
ep.routeRTCP(pkts)
}
}()
}
}
func (ep *endpoint) routeRTCP(pkts []rtcp.Packet) {
for _, pkt := range pkts {
switch fb := pkt.(type) {
case *rtcp.PictureLossIndication:
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
case *rtcp.FullIntraRequest:
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
case *rtcp.TransportLayerNack:
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
case *rtcp.SenderReport:
for _, target := range ep.sfu.mainTargets(ep) {
target.writeRTCP(pkt)
}
}
}
}
func (s *pionSFU) writeRTCPToPublisher(callID int64, mediaSSRC uint32, pkt rtcp.Packet) {
publisher := s.publisherBySSRC(callID, mediaSSRC)
if publisher != nil {
publisher.writeRTCP(pkt)
}
}
func (ep *endpoint) writeRTCP(pkt rtcp.Packet) {
ep.mu.Lock()
w := ep.rtcpWriter
ep.mu.Unlock()
if w == nil {
return
}
raw, err := rtcp.Marshal([]rtcp.Packet{pkt})
if err != nil {
return
}
_, _ = w.Write(raw)
}
// serveData 在 DTLS 连接上被动承接 SCTP 关联与 DCEP data channel
// - 客户端总是 SCTP INIT 与 DATA_CHANNEL_OPEN 的发起方sid=0, label="data"
// SFU 绝不主动 OPEN客户端没有 accept 入站 OPEN 的逻辑);
// - 关联被客户端重建restartDataChannel时在同一 DTLS 上重新 accept
// - 数据通道上没有任何消息是音视频工作的硬前置v1 只消费(记录)
// ReceiverVideoConstraints其余忽略。
func (ep *endpoint) serveData(conn *dtls.Conn) {
logf := logging.NewDefaultLoggerFactory()
logf.DefaultLogLevel = logging.LogLevelError
for {
select {
case <-ep.closed:
return
default:
}
assoc, err := sctp.Server(sctp.Config{
NetConn: conn,
LoggerFactory: logf,
})
if err != nil {
return // DTLS 关闭/endpoint 拆除
}
for {
dc, err := datachannel.Accept(assoc, &datachannel.Config{LoggerFactory: logf})
if err != nil {
break // 关联结束:外层重试承接客户端重建的新关联
}
go ep.serveDataChannel(dc)
}
_ = assoc.Close()
}
}
func (ep *endpoint) serveDataChannel(dc *datachannel.DataChannel) {
defer func() { _ = dc.Close() }()
buf := make([]byte, 65536)
for {
n, isString, err := dc.ReadDataChannel(buf)
if err != nil {
return
}
if !isString || n == 0 {
continue
}
ep.markActivity()
var msg struct {
ColibriClass string `json:"colibriClass"`
}
if err := json.Unmarshal(buf[:n], &msg); err != nil {
continue
}
// v1ReceiverVideoConstraints 仅记录(转发策略固定为 SIM[0] 层广播,
// 客户端对未订阅 endpoint 的包安全丢弃);选层/按订阅裁剪留下一轮。
ep.sfu.log.Debug("sfu data channel message",
zap.Int64("call_id", ep.callID),
zap.Int64("user_id", ep.userID),
zap.String("colibri_class", msg.ColibriClass))
}
}
func (ep *endpoint) writeRTP(packet []byte) {
ep.mu.Lock()
ws := ep.writeStream
ep.mu.Unlock()
if ws == nil {
return
}
_, _ = ws.Write(packet)
}
func (ep *endpoint) markActivity() {
ep.mu.Lock()
ep.lastActivity = time.Now()
ep.mu.Unlock()
}
func (ep *endpoint) aliveSince(cutoff time.Time) bool {
ep.mu.Lock()
defer ep.mu.Unlock()
return ep.connected && ep.lastActivity.After(cutoff)
}
func (ep *endpoint) close() {
ep.closeOnce.Do(func() {
close(ep.closed)
if ep.demux != nil {
ep.demux.Close()
}
_ = ep.agent.Close()
ep.mu.Lock()
ep.connected = false
ep.mu.Unlock()
})
}
// pionLoggerFactory 把 pion 日志降为静默(按需可接 zap
type pionLoggerFactory struct{ log *zap.Logger }
func (f *pionLoggerFactory) NewLogger(scope string) logging.LeveledLogger {
return logging.NewDefaultLoggerFactory().NewLogger(scope)
}