From 0cb6b75a543b2025b3d2e46f8ebf7779a353949e Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Fri, 17 Jul 2026 21:14:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=AB=AF=E4=B8=8D=E8=83=BD=E8=BF=9E=E6=8E=A5=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebRTCControllerWeb/server/index.js | 35 ++++++++++++++- .../src/services/SignalingClient.js | 32 ++++++++++++- .../src/store/controllerStore.js | 45 +++++++++++-------- .../signaling/config/WebSocketConfig.java | 17 +++++++ .../handler/SignalWebSocketHandler.java | 3 ++ 5 files changed, 110 insertions(+), 22 deletions(-) diff --git a/WebRTCControllerWeb/server/index.js b/WebRTCControllerWeb/server/index.js index cc3eb96..10994d4 100644 --- a/WebRTCControllerWeb/server/index.js +++ b/WebRTCControllerWeb/server/index.js @@ -7,7 +7,12 @@ import { WebSocketServer } from 'ws'; const PORT = Number(process.env.PORT || 8088); const PATH = process.env.WS_PATH || '/ws/signal'; -const wss = new WebSocketServer({ port: PORT, path: PATH }); +const wss = new WebSocketServer({ + port: PORT, + path: PATH, + // WebRTC SDP 消息可能较大,将最大负载设为 1MB(默认 100MB 但在某些代理环境下受限) + maxPayload: 1024 * 1024, +}); // deviceId -> { ws, type } const sessions = new Map(); @@ -17,10 +22,34 @@ const pending = new Map(); const keyOf = (a, b) => `${a}->${b}`; function send(ws, obj) { - if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj)); + if (ws && ws.readyState === ws.OPEN) { + try { + ws.send(JSON.stringify(obj)); + } catch (e) { + console.error('[send] 发送消息失败:', e.message); + } + } } +// —— 服务端主动 ping/pong 保活:每 30 秒向所有连接发送 ping, +// 若 60 秒内未收到 pong 则强制关闭连接(防止僵死连接占用资源)。 +const HEARTBEAT_INTERVAL = 30_000; +const heartbeatTimer = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws._alive === false) { + console.warn('[heartbeat] 连接无响应,强制关闭'); + return ws.terminate(); + } + ws._alive = false; + try { ws.ping(); } catch { /* ignore */ } + }); +}, HEARTBEAT_INTERVAL); +wss.on('close', () => clearInterval(heartbeatTimer)); + wss.on('connection', (ws) => { + ws._alive = true; + ws.on('pong', () => { ws._alive = true; }); + ws.on('message', (data) => { let msg; try { @@ -29,6 +58,8 @@ wss.on('connection', (ws) => { return; } const type = (msg.type || '').toUpperCase(); + // 忽略客户端心跳 ping,仅用于保活 + if (type === 'PING') return; switch (type) { case 'REGISTER': handleRegister(ws, msg); break; case 'DEVICE_LIST': handleDeviceList(ws); break; diff --git a/WebRTCControllerWeb/src/services/SignalingClient.js b/WebRTCControllerWeb/src/services/SignalingClient.js index 082e4c2..17e9568 100644 --- a/WebRTCControllerWeb/src/services/SignalingClient.js +++ b/WebRTCControllerWeb/src/services/SignalingClient.js @@ -18,6 +18,8 @@ export class SignalingClient { this.ws.onopen = () => { this.register(); this.onConnected && this.onConnected(); + // 启动心跳保活:每 25 秒发送 ping,防止中间代理或服务器因空闲超时断开连接 + this._startHeartbeat(); }; this.ws.onmessage = (ev) => { let msg; @@ -28,13 +30,38 @@ export class SignalingClient { } this.onMessage && this.onMessage(msg); }; - this.ws.onclose = () => this.onDisconnected && this.onDisconnected(); - this.ws.onerror = (e) => this.onError && this.onError(e?.message || 'WebSocket 错误'); + this.ws.onclose = (ev) => { + // 记录关闭码和原因,便于诊断断连根因: + // 1000 = 正常关闭, 1001 = 离开, 1006 = 异常断开(无 close frame) + // 1009 = 消息过大, 1011 = 服务端异常 + console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s', + ev.code, ev.reason || '(无)', ev.wasClean); + this._stopHeartbeat(); + this.onDisconnected && this.onDisconnected(ev.code, ev.reason); + }; + this.ws.onerror = (e) => { + console.error('[信令] WebSocket 错误', e); + this.onError && this.onError(e?.message || 'WebSocket 错误'); + }; } catch (e) { this.onError && this.onError(e.message); } } + _startHeartbeat() { + this._stopHeartbeat(); + this._heartbeatTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + // 发送轻量级 ping 消息,保持连接活跃(服务端会忽略未知类型消息) + try { this.ws.send(JSON.stringify({ type: 'PING' })); } catch { /* ignore */ } + } + }, 25000); + } + + _stopHeartbeat() { + if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; } + } + register() { this.send({ type: 'REGISTER', @@ -79,6 +106,7 @@ export class SignalingClient { } disconnect() { + this._stopHeartbeat(); if (this.ws) { this.ws.close(); this.ws = null; diff --git a/WebRTCControllerWeb/src/store/controllerStore.js b/WebRTCControllerWeb/src/store/controllerStore.js index c444558..556a9ab 100644 --- a/WebRTCControllerWeb/src/store/controllerStore.js +++ b/WebRTCControllerWeb/src/store/controllerStore.js @@ -13,9 +13,9 @@ export const DEFAULT_ICE_SERVERS = [ // { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' }, { urls: 'stun:175.178.213.60:3478' }, // { urls: 'stun:192.168.5.224:3478' }, -// { urls: 'stun:stun.l.google.com:19302' }, -// { urls: 'stun:stun1.l.google.com:19302' }, -// { urls: 'stun:stun2.l.google.com:19302' }, + // { urls: 'stun:stun.l.google.com:19302' }, + // { urls: 'stun:stun1.l.google.com:19302' }, + // { urls: 'stun:stun2.l.google.com:19302' }, ]; export const store = reactive({ @@ -128,21 +128,30 @@ export async function connectToDevice(targetId) { if (webrtc) { await webrtc.close(); webrtc = null; } - webrtc = new WebRtcController({ - iceServers: store.iceServers, - deviceId: store.deviceId, - targetDeviceId: targetId, - signaling, - onIceState: (s) => { - if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; } - else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; } - }, - onDataChannelState: (open) => { store.dataChannelOpen = open; }, - onStream: (stream) => { store.remoteStream = markRaw(stream); }, - onStats: (stats) => { store.stats = stats; }, - onError: (msg) => { if (msg) store.error = msg; }, - }); - await webrtc.createOffer(); + try { + webrtc = new WebRtcController({ + iceServers: store.iceServers, + deviceId: store.deviceId, + targetDeviceId: targetId, + signaling, + onIceState: (s) => { + if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; } + else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; } + }, + onDataChannelState: (open) => { store.dataChannelOpen = open; }, + onStream: (stream) => { store.remoteStream = markRaw(stream); }, + onStats: (stats) => { store.stats = stats; }, + onError: (msg) => { if (msg) store.error = msg; }, + }); + await webrtc.createOffer(); + } catch (e) { + // 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、 + // 间接影响 WebSocket 信令连接的存活。 + console.error('[connectToDevice] 创建 Offer 失败:', e); + store.error = '创建连接失败: ' + (e?.message || e); + store.statusText = '连接失败'; + if (webrtc) { await webrtc.close(); webrtc = null; } + } } export async function disconnectDevice() { diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java index 4cca7b7..0839d24 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java @@ -1,10 +1,12 @@ package com.ttstd.signaling.config; import com.ttstd.signaling.handler.SignalWebSocketHandler; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; @Configuration @EnableWebSocket @@ -16,6 +18,21 @@ public class WebSocketConfig implements WebSocketConfigurer { this.signalWebSocketHandler = signalWebSocketHandler; } + /** + * 配置 WebSocket 容器的消息大小限制与会话超时。 + *

默认文本消息缓冲区为 8KB,WebRTC SDP(OFFER/ANSWER)消息经 JSON 包装后 + * 可能超过该限制,导致服务端抛出 TextMessageLimitException 并关闭连接。 + * 此处将文本消息上限设为 512KB,二进制消息上限设为 512KB,会话空闲超时设为 10 分钟。 + */ + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(512 * 1024); // 512 KB + container.setMaxBinaryMessageBufferSize(512 * 1024); // 512 KB + container.setMaxSessionIdleTimeout(600_000L); // 10 分钟 + return container; + } + @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(signalWebSocketHandler, "/ws/signal") diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java index a8a63b6..7c548df 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java @@ -71,6 +71,9 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { case "DEVICE_LIST": handleDeviceList(session, signalMessage); break; + case "PING": + // 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接 + break; case "OFFER": // 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发 handleConnectionRequest(signalMessage);