标题
@larksuite/channel:握手超时(handshake timeout)竞态导致孤儿 WebSocket 抛出未捕获异常 "WebSocket was closed before the connection was established"
正文
涉及包:@larksuite/channel(实测版本 v0.3.0,通过第三方封装 lark-channel-bridge 间接使用,但问题根源在这个包本身)
现象
在网络不太稳定的环境下,我们的常驻进程会反复抛出一个未捕获异常:
uncaughtException: WebSocket was closed before the connection was established
大约 4 周的连续运行里,日志记录到这个报错出现了 1133 次。绝大多数是无害的(被我们自己顶层的 process.on('uncaughtException') 接住、只是打日志),但在一次真实的网络抖动期间,我们在约 500 行的日志窗口内密集出现了 26 次这个报错,同时伴随大量 network.unreachable(网络不可达)和 keepalive.force-reconnect(强制重连)事件,紧接着进程就真的崩溃了(通过 launchd 的 KeepAlive+ThrottleInterval 严格按 30 秒间隔重启,证实是真实的、反复的进程死亡,而不是良性重启)。
根因(在 dist/index.cjs 里定位到的)
connectWebSocket(timeoutMs) 大致逻辑是:
connectWebSocket(timeoutMs) {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new LarkChannelError("not_connected", `WebSocket handshake did not complete within ${timeoutMs}ms`));
}, timeoutMs);
this.rawWsClient = new WSClient({
...
onReady: () => { if (settled) return; settled = true; clearTimeout(timer); resolve(); },
onError: (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(...); },
...
});
this.rawWsClient.start({ eventDispatcher: this.dispatcher });
});
}
当 timeoutMs 定时器先触发(也就是握手在 handshakeTimeoutMs 时间内还没完成)时,外层 Promise 会 reject,但刚创建的这个 WSClient 实例(this.rawWsClient)并没有被主动关闭或中止——它会在后台继续尝试完成握手,成了一个和已经"放弃"它的代码脱钩的孤儿对象(调用方此时已经认为这次连接尝试"超时"了,可能已经通过 forceReconnect() 发起了新一轮连接)。
之后,当这个孤儿客户端底层的 ws socket 关闭时(可能是它自己的握手最终失败,也可能是 forceReconnect() 正在拆掉另一个 rawWsClient 引用、而这个旧的还残留在别处),底层 ws 库会抛出经典的 "WebSocket was closed before the connection was established" 错误。因为这个对象已经没人在监听了,Node 会把这个没人接住的 error 事件当作未捕获异常抛到进程顶层。
在网络持续不稳定的情况下,forceReconnect()/keepalive 会反复重试,每一次超时似乎都会泄漏一个这样的孤儿客户端(占用着 socket/定时器资源),密集发生时很可能会累积到资源耗尽——这和我们观察到的现象吻合:只有在这类报错密集爆发时才会崩溃,孤立的单次发生完全不会。
建议修复
在 timeoutMs 的定时器回调(和/或 onError)里,reject 之前先主动关闭/终止那个还在握手中的 WSClient/底层 socket,例如:
const timer = setTimeout(() => {
if (settled) return;
settled = true;
this.rawWsClient?.close?.({ force: true });
reject(new LarkChannelError("not_connected", `WebSocket handshake did not complete within ${timeoutMs}ms`));
}, timeoutMs);
或者在放弃这个底层 socket 之前,给它挂一个空的 error 监听器,这样它后续再关闭时就不会变成一个没人接住的事件。
我们目前的临时应对
写了一个通过 --require 预加载的小脚本,识别这一条已知的报错特征,如果 120 秒内密集出现 ≥5 次,就主动让进程退出,交给进程守护(launchd 的 KeepAlive)干净地重启,而不是等着资源耗尽后在不可预测的时刻崩溃。这只是治标,真正的修复还是要在这个包里做。
如果需要一个最小复现(反复在握手中途掐断网络),我们可以配合提供,麻烦看看是否方便处理,谢谢!
标题
@larksuite/channel:握手超时(handshake timeout)竞态导致孤儿 WebSocket 抛出未捕获异常"WebSocket was closed before the connection was established"正文
涉及包:
@larksuite/channel(实测版本 v0.3.0,通过第三方封装lark-channel-bridge间接使用,但问题根源在这个包本身)现象
在网络不太稳定的环境下,我们的常驻进程会反复抛出一个未捕获异常:
大约 4 周的连续运行里,日志记录到这个报错出现了 1133 次。绝大多数是无害的(被我们自己顶层的
process.on('uncaughtException')接住、只是打日志),但在一次真实的网络抖动期间,我们在约 500 行的日志窗口内密集出现了 26 次这个报错,同时伴随大量network.unreachable(网络不可达)和keepalive.force-reconnect(强制重连)事件,紧接着进程就真的崩溃了(通过launchd的KeepAlive+ThrottleInterval严格按 30 秒间隔重启,证实是真实的、反复的进程死亡,而不是良性重启)。根因(在
dist/index.cjs里定位到的)connectWebSocket(timeoutMs)大致逻辑是:当
timeoutMs定时器先触发(也就是握手在handshakeTimeoutMs时间内还没完成)时,外层 Promise 会 reject,但刚创建的这个WSClient实例(this.rawWsClient)并没有被主动关闭或中止——它会在后台继续尝试完成握手,成了一个和已经"放弃"它的代码脱钩的孤儿对象(调用方此时已经认为这次连接尝试"超时"了,可能已经通过forceReconnect()发起了新一轮连接)。之后,当这个孤儿客户端底层的
wssocket 关闭时(可能是它自己的握手最终失败,也可能是forceReconnect()正在拆掉另一个rawWsClient引用、而这个旧的还残留在别处),底层ws库会抛出经典的"WebSocket was closed before the connection was established"错误。因为这个对象已经没人在监听了,Node 会把这个没人接住的error事件当作未捕获异常抛到进程顶层。在网络持续不稳定的情况下,
forceReconnect()/keepalive 会反复重试,每一次超时似乎都会泄漏一个这样的孤儿客户端(占用着 socket/定时器资源),密集发生时很可能会累积到资源耗尽——这和我们观察到的现象吻合:只有在这类报错密集爆发时才会崩溃,孤立的单次发生完全不会。建议修复
在
timeoutMs的定时器回调(和/或onError)里,reject 之前先主动关闭/终止那个还在握手中的WSClient/底层 socket,例如:或者在放弃这个底层 socket 之前,给它挂一个空的
error监听器,这样它后续再关闭时就不会变成一个没人接住的事件。我们目前的临时应对
写了一个通过
--require预加载的小脚本,识别这一条已知的报错特征,如果 120 秒内密集出现 ≥5 次,就主动让进程退出,交给进程守护(launchd的KeepAlive)干净地重启,而不是等着资源耗尽后在不可预测的时刻崩溃。这只是治标,真正的修复还是要在这个包里做。如果需要一个最小复现(反复在握手中途掐断网络),我们可以配合提供,麻烦看看是否方便处理,谢谢!