视频会议保存
This commit is contained in:
112
media-server/src/utils/port-pool.ts
Normal file
112
media-server/src/utils/port-pool.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* UDP 端口池:用于为录制 ffmpeg 子进程分配可用 RTP/RTCP 端口对
|
||||
*
|
||||
* 设计要点:
|
||||
* - 端口范围与 mediasoup RTC 端口范围(默认 40000-40199)必须不相交,否则会与
|
||||
* mediasoup 自身的 PlainTransport 本地绑定端口冲突
|
||||
* - 通过 dgram.createSocket('udp4') 试探绑定来确认端口未被占用,不依赖 lsof / 系统接口
|
||||
* - 一次申请通常成对返回 [rtp, rtcp],习惯上 rtp = even, rtcp = rtp+1(RFC 3550 §11)
|
||||
* - 失败重试上限 ALLOC_RETRY,超过则抛错;此时通常意味着系统并发录制数过多
|
||||
*
|
||||
* 释放策略:
|
||||
* - 调用方在录制 stop 时调 release(rtp, rtcp) 主动释放,避免端口长期被占
|
||||
* - 若调用方忘记 release,进程退出时端口自然回收(OS 层),不会泄漏到下次启动
|
||||
*
|
||||
* 并发:
|
||||
* - reservedPorts 是单进程内存集合,配合 dgram 试探绑定双重防御并发竞态
|
||||
*/
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
import { childLogger } from './logger.js';
|
||||
|
||||
const log = childLogger({ module: 'utils.port-pool' });
|
||||
|
||||
const RECORDING_PORT_MIN = 50000;
|
||||
const RECORDING_PORT_MAX = 59998;
|
||||
const ALLOC_RETRY = 64;
|
||||
|
||||
const reservedPorts = new Set<number>();
|
||||
|
||||
function isInRange(port: number): boolean {
|
||||
return port >= RECORDING_PORT_MIN && port <= RECORDING_PORT_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* 试探指定端口是否可用:
|
||||
* - 创建 UDP socket 尝试 bind
|
||||
* - 成功立即 close 释放,返回 true
|
||||
* - 失败(EADDRINUSE / EACCES 等)返回 false
|
||||
*/
|
||||
function probeUdpPort(port: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let resolved = false;
|
||||
const finalize = (ok: boolean) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once('error', () => finalize(false));
|
||||
try {
|
||||
socket.bind(port, '0.0.0.0', () => finalize(true));
|
||||
} catch {
|
||||
finalize(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配一对 (rtp, rtcp) 端口;rtp 为偶数,rtcp = rtp + 1
|
||||
*
|
||||
* @returns { rtp, rtcp } 已通过试探确认空闲;调用方使用前不再二次试探
|
||||
* 注意试探与实际使用之间存在 TOCTOU 窗口,调用方需对 ffmpeg listen 失败做兜底
|
||||
* @throws 重试 ALLOC_RETRY 次仍失败时抛错
|
||||
*/
|
||||
export async function allocatePortPair(): Promise<{ rtp: number; rtcp: number }> {
|
||||
for (let i = 0; i < ALLOC_RETRY; i++) {
|
||||
// 在 [MIN, MAX-1] 内随机偶数;保证 +1 后 rtcp 仍 ≤ MAX+1
|
||||
// RTP 端口必须偶数,RTCP 必须 RTP+1(mediasoup 默认行为)
|
||||
const span = (RECORDING_PORT_MAX - RECORDING_PORT_MIN) >> 1;
|
||||
const offset = Math.floor(Math.random() * span) * 2;
|
||||
const rtp = RECORDING_PORT_MIN + offset;
|
||||
const rtcp = rtp + 1;
|
||||
if (!isInRange(rtp) || !isInRange(rtcp)) continue;
|
||||
if (reservedPorts.has(rtp) || reservedPorts.has(rtcp)) continue;
|
||||
|
||||
// 双端口都试探
|
||||
const [okRtp, okRtcp] = await Promise.all([probeUdpPort(rtp), probeUdpPort(rtcp)]);
|
||||
if (!okRtp || !okRtcp) continue;
|
||||
|
||||
reservedPorts.add(rtp);
|
||||
reservedPorts.add(rtcp);
|
||||
return { rtp, rtcp };
|
||||
}
|
||||
throw new Error(`port-pool: failed to allocate UDP port pair after ${ALLOC_RETRY} retries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放端口对。幂等:未在 reservedPorts 中的端口忽略
|
||||
*/
|
||||
export function releasePortPair(rtp: number, rtcp: number): void {
|
||||
reservedPorts.delete(rtp);
|
||||
reservedPorts.delete(rtcp);
|
||||
log.debug({ rtp, rtcp, reservedSize: reservedPorts.size }, 'port pair released');
|
||||
}
|
||||
|
||||
export function getPortPoolStats(): { reserved: number; min: number; max: number } {
|
||||
return {
|
||||
reserved: reservedPorts.size,
|
||||
min: RECORDING_PORT_MIN,
|
||||
max: RECORDING_PORT_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
/** 测试专用:复位端口集合 */
|
||||
export function _clearPortPool(): void {
|
||||
reservedPorts.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user