Improve: Park duplicate online connections as standby, promote on old removal

Replacing the old connection on a duplicate login made the client
reconnect in a tight loop. Instead, park the new connection in a standby
list keyed by clientID and promote it into the main host list only after
the old connection is removed by heartbeat timeout or disconnect.

Fix heartbeat attribution so a parked standby refreshes its own
last-heartbeat time rather than the main connection's, otherwise the main
never times out and the standby can never take over. Gate the offline
notification on an actual removal with no promoted standby to avoid a
spurious "host offline" when takeover happens. Skip promoting a standby
already marked removed, and reuse the normal login sign/settings path when
a standby is promoted.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-21 19:35:43 +02:00
parent d359148841
commit 8de5c00a39
7 changed files with 144 additions and 27 deletions

View File

@@ -1638,11 +1638,14 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
return;
}
if (!ctx->GetClientData(ONLINELIST_IP).IsEmpty()) {
Mprintf("上线消息 - 主机已经存在 [2]: %llu. IP= %s. Path= %s\n", id, data[ONLINELIST_IP], path);
#ifndef _DEBUG
Mprintf("上线消息 - 主机重复上线,暂存替补 [2]: %llu. IP= %s. Path= %s\n", id, data[ONLINELIST_IP], path);
// 网络抖动导致客户端用新连接重复上线:这里不立即替换/关闭旧连接,
// 否则旧连接被关会触发客户端反复重连抖动(更新前就一直在"替换旧连接")。
// 改为把新连接暂存到替补列表,旧连接因心跳超时或断开被移除后再由
// PromoteStandby 取替补转正。
AddToStandbyList(ContextObject);
LeaveCriticalSection(&m_cs);
return;
#endif
}
}
}
@@ -3689,15 +3692,23 @@ void CMy2015RemoteDlg::CheckHeartbeat()
for (context* ContextObject : toRemove) {
auto host = ContextObject->GetAdditionalData(RES_CLIENT_PUBIP);
host = host.IsEmpty() ? std::to_string(ContextObject->GetClientID()).c_str() : host;
Mprintf("Client %s[%llu] heartbeat timeout!!! \n", host, ContextObject->GetClientID());
if (m_needNotify)
PostMessageA(WM_SHOWNOTIFY, (WPARAM)new CharMsg(_TR("主机掉线")),
(LPARAM)new CharMsg(_TR("主机长时间无心跳: ") + host));
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(_TR("[主机下线] 主机长时间无心跳: ") + host), NULL);
Mprintf("主机 %s[%llu]心跳超时\n", host, ContextObject->GetClientID());
int port = ContextObject->GetPort();
RemoveFromHostList(ContextObject);
uint64_t clientID = ContextObject->GetClientID();
// 旧连接长期无心跳被移除:若有替补连接在等待,取替补转正。
// 只有真正下线(无替补转正)时才提示"主机下线",避免替补接替时误报。
bool promoted = RemoveFromHostList(ContextObject) && PromoteStandby(clientID);
if (!promoted) {
Mprintf("Client %s[%llu] heartbeat timeout!!! \n", host, clientID);
if (m_needNotify)
PostMessageA(WM_SHOWNOTIFY, (WPARAM)new CharMsg(_TR("主机掉线")),
(LPARAM)new CharMsg(_TR("主机长时间无心跳: ") + host));
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(_TR("[主机下线] 主机长时间无心跳: ") + host), NULL);
Mprintf("主机 %s[%llu]心跳超时\n", host, clientID);
} else {
Mprintf("主机 %s[%llu]心跳超时,替补连接已转正接替\n", host, clientID);
}
// 从待上线队列中移除(防止定时器访问已释放的 context
auto pit = std::find(m_PendingOnline.begin(), m_PendingOnline.end(), ContextObject);
@@ -5148,6 +5159,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
// Remove from host list and pending online queue
info->hasLogin = g_2015RemoteDlg->RemoveFromHostList(ContextObject);
// 无论断开的是主连接还是替补,都从替补列表里移除(幂等)。
g_2015RemoteDlg->RemoveFromStandbyList(ContextObject);
// 主连接被移除且有替补在等待时,立即取替补转正,避免主机短暂消失。
if (info->hasLogin) {
g_2015RemoteDlg->PromoteStandby(info->clientId);
}
auto& pending = g_2015RemoteDlg->m_PendingOnline;
auto it = std::find(pending.begin(), pending.end(), (context*)ContextObject);
if (it != pending.end()) {
@@ -6652,6 +6669,70 @@ bool CMy2015RemoteDlg::RemoveFromHostList(context* ctx)
return removed;
}
void CMy2015RemoteDlg::AddToStandbyList(CONTEXT_OBJECT* ctx)
{
if (!ctx) return;
uint64_t clientID = ctx->GetClientID();
auto it = m_StandbyHosts.find(clientID);
if (it != m_StandbyHosts.end() && it->second != ctx) {
// 已有旧替补:说明客户端又断线重连了,旧替补已失效,关闭它释放连接。
Mprintf("替补列表:替换旧替补 [%llu]\n", clientID);
it->second->CancelIO();
}
m_StandbyHosts[clientID] = ctx;
Mprintf("替补列表:加入替补 [%llu],当前替补数=%zu\n", clientID, m_StandbyHosts.size());
}
void CMy2015RemoteDlg::RemoveFromStandbyList(CONTEXT_OBJECT* ctx)
{
if (!ctx) return;
auto it = m_StandbyHosts.find(ctx->GetClientID());
// 只有当前替补正是该 ctx 时才移除,避免误删更新后的替补(幂等)。
if (it != m_StandbyHosts.end() && it->second == ctx) {
m_StandbyHosts.erase(it);
Mprintf("替补列表:移除替补 [%llu],当前替补数=%zu\n", ctx->GetClientID(), m_StandbyHosts.size());
}
}
bool CMy2015RemoteDlg::PromoteStandby(uint64_t clientID)
{
auto it = m_StandbyHosts.find(clientID);
if (it == m_StandbyHosts.end())
return false;
CONTEXT_OBJECT* standby = it->second;
// 替补连接可能已被 RemoveStaleContext 标记移除(其 OfflineProc 正在等 m_cs
// 此时转正只会让主机短暂闪断后再次下线。跳过并清理,避免误转正已死的连接。
if (standby->IsRemoved.load()) {
m_StandbyHosts.erase(it);
Mprintf("替补转正:主机 [%llu] 的替补连接已被移除,放弃转正\n", clientID);
return false;
}
m_StandbyHosts.erase(it);
// 替补转正:加入主列表并更新索引(与 AddList 正常上线流程一致)。
m_ClientIndex[clientID] = m_HostList.size();
m_HostList.push_back(standby);
if (WebService().IsRunning()) {
WebService().MarkDeviceOnline(clientID);
}
m_PendingOnline.push_back(standby);
Mprintf("替补转正:主机 [%llu] 由替补连接接替上线\n", clientID);
// 重复上线时未补发过 MasterSettings这里补发含签名让客户端完成登录配置。
std::string signMessage(const std::string & privateKey, BYTE * msg, int len);
CString startTime = standby->GetClientData(ONLINELIST_STARTTIME);
MasterSettings copy = m_settings;
std::string msg = startTime.GetString();
msg += "|" + std::to_string(clientID);
auto signature = signMessage("", (BYTE*)msg.c_str(), msg.length());
ASSERT(signature.size() <= sizeof(copy.Signature));
memcpy(copy.Signature, signature.data(), signature.size());
SendMasterSettings(standby, copy);
return true;
}
LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
{
// OfflineProc already removed context from m_HostList/m_PendingOnline and added to m_PendingOffline.
@@ -6661,23 +6742,24 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
return S_OK;
}
// Show offline notification
if (!info->ip.IsEmpty() && info->hasLogin) {
// 判断主连接是否仍在线OfflineProc 已在 IO 线程持锁内完成 RemoveFromHostList
// 若有替补连接被 PromoteStandby 转正m_ClientIndex 里仍会有该 clientId。
// 直接查 m_ClientIndex 比依赖 hasLogin 更稳健:既不受子连接 auth 改造影响,
// 也能避免替补转正时误报"主机下线"。
bool stillOnline = false;
if (info->clientId != 0) {
CLock L(m_cs);
stillOnline = (m_ClientIndex.find(info->clientId) != m_ClientIndex.end());
}
// Show offline notification仅在主连接断开且无替补转正时才提示真正下线
if (!info->ip.IsEmpty() && info->hasLogin && !stillOnline) {
ShowMessage(_TR("操作成功"), info->ip + " " + _TR("主机下线") + "[" + info->aliveInfo.c_str() + "]");
Mprintf("%s 主机下线 [%s]\n", info->ip.GetString(), info->aliveInfo.c_str());
}
// 关闭对应客户端的循环快照浮窗如有。CloseLoopTip 内部 find 找不到会静默返回。
if (info->clientId != 0) {
// 判断主连接是否仍在线OfflineProc 已在 IO 线程持锁内完成 RemoveFromHostList
// 若 m_ClientIndex 里仍有该 clientId说明还有另一条连接在列表中即本次断开的
// 是子连接),不应清理主连接的 UI 状态;反之说明主机真正下线。
// 直接查 m_ClientIndex 比依赖 hasLogin 更稳健:不受未来子连接 auth 改造影响。
bool stillOnline;
{
CLock L(m_cs);
stillOnline = (m_ClientIndex.find(info->clientId) != m_ClientIndex.end());
}
if (!stillOnline) {
// 主连接真正下线:关循环窗、释放缩略图 HBITMAP、清调度状态。
CloseLoopTip(info->clientId);
@@ -7093,7 +7175,10 @@ void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
ctx->SetClientData(ONLINELIST_VIDEO, newVideo);
changed = true;
}
id->SetLastHeartbeat(time(0));
// 心跳必须刷新"实际发送者" ctx 的心跳,而不是按 clientID 查到的 id主连接
// 否则替补连接ctx != id的心跳会把主连接的心跳也刷新掉导致主连接永不超时、
// 替补永远无法转正。ctx 自身心跳已在函数前段记录,这里在锁内再做一次权威更新。
ctx->SetLastHeartbeat(time(0));
if (changed) {
m_DirtyClients.insert(clientID);