修复通话发起后立即退出登录导致应用整体白屏崩溃

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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 05:09:23 +08:00
co-authored by Claude Sonnet 5
parent 4a140e6c79
commit 5b836d50b2
+35 -27
View File
@@ -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() {