From 5b836d50b2ce35e20f061b727b1362fb35b0cbd6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 05:09:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=9A=E8=AF=9D=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E5=90=8E=E7=AB=8B=E5=8D=B3=E9=80=80=E5=87=BA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=AF=BC=E8=87=B4=E5=BA=94=E7=94=A8=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleLogout()先断开WS连接、再重置界面状态,界面重置卸载 MessagesView时其componentWillUnmount会尝试发一条挂断信令,vendored SDK的Connection.sendText()在已关闭的socket上同步抛错,componentWillUnmount 里没有error boundary兜底,导致整个应用被React卸载成白屏。 5处几乎相同的通话信令发送方法(ringing/hang-up/offer/ice-candidate/ answer)合并成一个sendCallSignal()辅助方法,发送前检查连接状态、 发送本身包一层try/catch。 第十二轮最小粒度测试 Ch8 发现(点击通话按钮后立即退出登录复现)。 Co-Authored-By: Claude Sonnet 5 --- src/views/tinode-web.jsx | 62 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index 1651048f..3f2d917b 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -2511,48 +2511,56 @@ class TinodeWeb extends React.Component { } } - handleCallRinging(callTopic, callSeq) { + // All of the call-signaling senders below (ringing/hang-up/offer/ice-candidate/answer) go through + // this one helper rather than calling topic.videoCall() directly. handleCallHangup in particular is + // called from several unmount/teardown paths outside the normal call flow (messages-view.jsx's + // componentWillUnmount and its topic-switch handler in componentSetup both call it via the + // onCallHangup prop, on top of the ring-timeout path and CallInvite's own cleanup here) -- including + // ones that fire *after* a logout has already torn down the connection: handleLogout() calls + // this.tinode.disconnect() and only afterwards resets state to the blank/logged-out screen, and that + // state reset is what unmounts messages-view.jsx and trips its componentWillUnmount -> handleCallHangup + // call. Real repro: click a call button (real or scripted handleStartVideoCall/handleStartAudioCall), + // then immediately log out -- topic.videoCall() ends up calling the vendored SDK's + // Connection.sendText() on an already-closed socket, which throws synchronously ("Websocket is not + // connected"). Thrown from componentWillUnmount, React has no error boundary for it and unmounts the + // entire app to a blank white page. None of these are anything the peer can still receive over a dead + // connection anyway (and for hang-up specifically, the server's own establishment-timeout already + // cleans up an abandoned call-in-progress independently), so skip sending entirely once disconnected + // instead of letting it throw -- the other four signals are currently unreachable in practice (no real + // call can get established while "call invite always 400s" -- see handleCallInvite -- is unfixed) but + // share the identical topic.videoCall() shape and would hit the exact same crash the moment that's + // fixed and real in-progress calls (offer/ice-candidate/answer exchange, or a mid-call logout) become + // possible. + sendCallSignal(callTopic, evt, callSeq, payload) { const topic = this.tinode.getTopic(callTopic); - if (!topic) { + if (!topic || !this.tinode.isConnected()) { return; } + try { + topic.videoCall(evt, callSeq, payload); + } catch (err) { + console.warn(`Failed to send call signal '${evt}'`, err); + } + } - topic.videoCall('ringing', callSeq); + handleCallRinging(callTopic, callSeq) { + this.sendCallSignal(callTopic, 'ringing', callSeq); } handleCallHangup(callTopic, callSeq) { - const topic = this.tinode.getTopic(callTopic); - if (!topic) { - return; - } - - topic.videoCall('hang-up', callSeq); + this.sendCallSignal(callTopic, 'hang-up', callSeq); } handleCallSendOffer(callTopic, callSeq, sdp) { - const topic = this.tinode.getTopic(callTopic); - if (!topic) { - return; - } - - topic.videoCall('offer', callSeq, sdp); + this.sendCallSignal(callTopic, 'offer', callSeq, sdp); } handleCallIceCandidate(callTopic, callSeq, candidate) { - const topic = this.tinode.getTopic(callTopic); - if (!topic) { - return; - } - - topic.videoCall('ice-candidate', callSeq, candidate); + this.sendCallSignal(callTopic, 'ice-candidate', callSeq, candidate); } - handleCallSendAnswer(callTopic, callSeq, sdp) { - const topic = this.tinode.getTopic(callTopic); - if (!topic) { - return; - } - topic.videoCall('answer', callSeq, sdp); + handleCallSendAnswer(callTopic, callSeq, sdp) { + this.sendCallSignal(callTopic, 'answer', callSeq, sdp); } handleCallClose() {