Skip to content

MQTT 断线时云 API 或 DNS 异常会导致 Paho 网络线程永久退出 #67

Description

@bibbyee

问题描述

Classic 平台的 MQTT 连接异常断开时,libdeye 会在 Paho 的 on_disconnect 回调中刷新 MQTT 连接信息。

如果刷新过程中发生临时 DNS 超时或德业云 API 连接异常,异常会通过 Future.result() 重新抛回 Paho 网络线程,最终导致该线程永久退出。

发生后会出现:

  • Home Assistant 不再收到设备状态;
  • Home Assistant 无法控制设备;
  • HA 中仍可能显示之前缓存的状态;
  • 官方 App 状态和控制正常;
  • 新启动的 deye-cli 可以查询和控制同一设备;
  • 只有重新加载集成、创建新的 MQTT Client 后才能恢复。

环境信息

  • libdeye:2.1.5
  • ha-deye-dehumidifier:2.1.7
  • Home Assistant:2025.6.1
  • 安装方式:Home Assistant Container
  • Python:3.13
  • 设备型号:DYD-W20A3-京鱼座
  • 设备平台:Classic

相关代码

当前异常断线处理逻辑:

def _mqtt_on_disconnect(
    self,
    _mqtt: mqtt.Client,
    _userdata: None,
    result_code: int,
) -> None:
    if result_code == 0:
        return

    asyncio.run_coroutine_threadsafe(
        self._set_mqtt_info(),
        self._loop,
    ).result()

这个回调运行在 Paho MQTT 网络线程中。

Future.result() 存在两个问题:

  1. 它会同步阻塞 Paho 网络线程,直到云 API 请求完成;
  2. _set_mqtt_info() 抛出的异常会通过 result() 重新抛到 Paho 网络线程。

如果异常没有被捕获,Paho 网络线程会直接结束,后续无法再执行自动重连、心跳、消息接收和消息发送。

实际日志

ERROR (paho-mqtt-client-) [root] Uncaught thread exception

aiodns.error.DNSError:
(12, 'Timeout while contacting DNS servers')

aiohttp.client_exceptions.ClientConnectorDNSError:
Cannot connect to host api.deye.com.cn:443
[Timeout while contacting DNS servers]

File ".../libdeye/mqtt_client.py", line 82,
in _mqtt_on_disconnect
    asyncio.run_coroutine_threadsafe(
        self._set_mqtt_info(),
        self._loop,
    ).result()

libdeye.cloud_api.DeyeCloudApiCannotConnectError

堆栈最终结束于:

threading.Thread._bootstrap_inner

说明异常从 Paho 回调中逃逸,并终止了 MQTT 网络线程。

复现方式

  1. 使用 DeyeClassicMqttClient 连接 Classic 平台设备;
  2. 等待 MQTT 正常连接并收发消息;
  3. 触发一次非主动 MQTT 断线;
  4. on_disconnect 刷新 MQTT 信息期间,让 api.deye.com.cn 出现 DNS 超时或临时不可访问;
  5. 日志中出现 Uncaught thread exception
  6. 恢复网络或 DNS;
  7. 原有 MQTT Client 仍然无法恢复;
  8. 新启动的 deye-cli 可以连接和控制同一设备;
  9. 重新加载 HA 集成后恢复。

期望行为

临时 DNS 或云 API 请求失败应该被记录,但不能:

  • 无限阻塞 Paho 网络线程;
  • 让异常逃逸到 Paho 回调线程;
  • 导致 MQTT 网络循环永久退出。

刷新失败后,Paho 网络线程应该继续运行,并保留后续自动重连的能力。

实际行为

Future.result()DeyeCloudApiCannotConnectError 重新抛入 Paho 网络线程,导致 MQTT 网络循环永久终止。

建议修复方案

建议:

  1. 不要在 Paho 回调中同步等待 Future.result()
  2. 将 MQTT 信息刷新任务提交到 asyncio 事件循环后立即返回;
  3. 为刷新任务设置有限超时,例如 15 秒;
  4. 捕获 DNS、云 API 和其他刷新异常;
  5. 避免断线期间重复创建多个刷新任务;
  6. 主动断开客户端时取消尚未完成的刷新任务;
  7. 增加以下测试:
    • 断线回调不会调用 Future.result()
    • 云 API 异常不会逃逸到 Paho 网络线程;
    • 刷新超时会被正确处理;
    • 多次断线回调不会创建重复刷新任务。

本地已经实现并验证了上述方案,libdeye 的 116 个测试全部通过。

如果维护者认可这个处理方式,我可以继续提交对应 PR。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions