视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

View 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+1RFC 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+1mediasoup 默认行为)
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();
}