XMPP 连接状态与超时处理
商业 IM 软件的 Dart 客户端连上 Socket 以后,还要初始化协议流、认证、绑定资源,才能发业务消息。
所以这里的 connected 和 ready 是两个状态。前面记过重连后的历史补偿,这篇补一下连接本身的流程,以及超时、重试和清理放到一起时容易漏掉的地方。
连接状态
项目定义了下面几个连接状态:
| 状态 | 在这套客户端中的含义 |
|---|---|
disconnected | 当前没有处于可用连接流程中 |
connecting | 正在建立网络连接 |
connected | 网络连接已建立,协议准备仍未完成 |
authenticating | 正在认证身份 |
authenticated | 认证完成,后续绑定等步骤仍在进行 |
ready | 当前连接初始化流程完成 |
其中,对外的 isConnected 实际按 ready 判断:
bool get isConnected => _connectionState == XmppConnectionState.ready;
这里的命名容易让人误会,所以理解代码时要看具体判断,不能只看单词。
底层 sendXml 则没有要求所有发送都必须在 ready 之后。认证和绑定过程本身也要发送协议数据,发送入口与业务可用状态自然不能完全等同。
另外,定义了这些枚举,不代表代码已经自动限制了合法状态转换。当前状态更新方法主要负责赋值和广播,允许从哪里到哪里,仍然分散在具体调用流程中。
按阶段超时,才能知道卡在哪里
Token 连接路径会分别等待网络连接、服务器特性、认证响应、重新初始化流和资源绑定。
当前代码中相关等待常量如下:
| 等待范围 | 配置值 |
|---|---|
| 网络建立 | 10 秒 |
| 认证 | 8 秒 |
| 协议流等待 | 5 秒 |
| 绑定 | 5 秒 |
| 单轮连接尝试的外围等待 | 默认 20 秒 |
这些是当前实现的参数,不是适用于所有网络的推荐配置。
阶段超时方便定位失败点;外围等待则用于限制这一轮连接尝试。两者同时存在时,阶段预算之和不等于实际一定等待的时间,外围等待可能先结束。
如果日志里只写“连接超时”,网络连不上和认证阶段没拿到响应就混在一起了。记录阶段,比单纯记录异常文本更容易关联到实际处理动作。
“总超时”可能只限制了一轮
当前连接入口在重试循环内部创建超时竞争。也就是说,每次进入下一轮,都会重新获得这一轮的等待预算。
_maxTokenRetryAttempts 为 2,循环包含首次尝试,所以最多会进入三轮。默认的 20 秒不能解释为整个登录操作最多等待 20 秒。
两次重试之间还会等待退避时间。当前退避计算为:
final delay = _initialRetryDelay * (1 << (attempt - 1));
return delay > Duration(seconds: 5) ? Duration(seconds: 5) : delay;
初始延迟是 800 毫秒,在这条最多两次重试的路径中,对应 800 毫秒和 1.6 秒。
如果产品需要限制整个登录动作的时间,可以在入口保存一个截止时间,每个阶段和下一次重试都使用剩余预算。这是另一种契约,不能只给循环内部的参数起名为“总超时”就认为已经实现。
超时以后,旧连接任务可能还在继续
连接流程使用 Future.any 让实际操作和超时 Future 竞争。Dart 的语义是采用最先完成的结果,其他结果不用于返回值;它没有在这个方法里替调用方取消其他任务。参见 Future.any 官方文档。
因此需要考虑这样的时序:
第一轮连接开始
→ 外围等待超时
→ 开始清理并安排下一轮
→ 第一轮内部的异步操作迟到完成
旧结果是否还能更新 Socket、状态或者 Token,需要另外约束。
前面离线补偿文章提到过账号和任务代次。类似思路也可以用于连接尝试:每次尝试有自己的身份,在异步边界检查它是否仍然有效。
消息补偿层已经有代次校验,底层连接流程还需要单独补。
认证错误和网络错误,不应该用同一种重试方式
当前重试循环遇到 AuthenticationException 会停止;Socket、超时等错误则可能进入重试。
这能减少对明确认证失败的重复尝试。但错误分类还要看完整传播路径:当前最终收尾中,部分非认证错误又会被包装成认证异常交给等待方。
这说明“内部按错误分类”与“外部拿到准确的错误类别”也需要一起检查。否则 UI 可能无法正确区分应该重新登录,还是稍后再尝试连接。
同样,当前可重试判断中还包含异常文本匹配。它可以兼容一些没有明确类型的错误,但如果文本变化,分类结果也可能变化。长期维护时,更稳定的错误类型和阶段信息会更容易使用。
心跳与重连,需要处理同一轮生命周期
客户端的 Ping 逻辑会记录连续失败次数,成功后清零,达到阈值再进入连接丢失处理。当前阈值是三次,单次等待为 15 秒。
这是检测策略的取舍:允许短暂失败,可以减少瞬时波动带来的恢复动作;同时也意味着确认异常需要时间。这些数值不能直接相乘当成精确的断线发现时间,因为还有定时调度和重试路径。
代码里也有不止一种恢复入口,例如连接丢失后的定时尝试,以及断开处理中的退避重连。
如果要进一步收敛,我会让连接管理器统一决定是否重试,各个入口只报告发生了什么。当前不能因为某条路径有锁,就把所有恢复任务都描述为已经统一调度。
清理保护,也可能让清理被跳过
资源清理里有一个值得单独记录的细节。
_safeCleanup 先检查 _isCleaningUp,然后设为 true,再调用 _cleanup。但 _cleanup 的第一行同样是:
if (_isCleaningUp) return;
顺着这条调用路径看,外层刚设好的标志,会让内层直接返回。它原本想防止重复清理,结果却可能跳过实际释放资源的步骤。
这是当前源码中的调用关系,不代表所有清理入口都失效;直接调用 _cleanup 的路径还需要分别看。
清理是否完成,也应该能观察到具体事实:定时器已取消、订阅已结束、Socket 已关闭、排队任务已经获得结束结果。仅仅把状态改成 disconnected,不能代替这些动作。
这处清理标志值得先改:入口负责挡住重复调用,内部函数专门释放资源。两个函数都判断同一个标志,反而把真正的清理挡掉了。