
一次掉线不该拖垮整个应用 — 可我们的却垮了
当后端在会议中途掉线时,它不只是暂停了转录 — 它让整个应用崩溃了。原因是单个未被处理的事件,而修复只有十行。这是让 GeekBye 在那些曾经会杀死它的状况中依然保持登录、保持连接的那一组发布的故事。
有些 bug 打断一个功能。最糟糕的那些会拖垮它周围的一切。在 GeekBye 早期的一个构建里,如果你正开着会而后端掉线,转录不只是停下 — 整个应用崩溃了。 一次掉线,整个窗口就没了。
修复这件事的那一组发布 — 从 v1.6.8 到 v1.6.11 — 是“保持登录、保持连接”这种不起眼工作的一堂大师课。头号修复只有十行。这就是那十行到底是什么,以及和它一起发布的其他一切。
断线与崩溃之间的十行
崩溃藏在音频代码里。当一个转录会话拆除它的 WebSocket 时,它对它们调用了 removeAllListeners() — 合情合理的清理。但这里有一个把清理变成灾难的 Node.js 细节:一个没有监听器的 error 事件不会被忽略。它会被重新抛为未捕获异常。
所以想象一下 后端掉线 时的顺序:套接字有一个 error 事件 排在队列里 等着触发。清理运行了 removeAllListeners(),剥掉了本该接住它的处理器。片刻之后,排队的错误触发了 — 触发进虚空。Node 看到一个没有人在听的 error 事件,就做它的契约唯一允许的事:把它当作未捕获异常抛出,从而让进程崩溃。用户的后端打了两秒的嗝,他们的应用就消失了。
这个修复(v1.6.8)几乎让人扫兴。在剥掉监听器之后,立刻重新挂上一个刻意的 空操作 error 处理器:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
那个空函数就是全部要点。它给了待触发的错误一个落脚的地方 — 一个汇 — 于是 Node 有了监听器,永远不会重新抛出。同一个提交还在连接尝试中途失败时清理了半开的套接字,这样一次搞砸的连接就不会留下一个活着的、等着触发错误的套接字悬在那里。提交注释说得很直白:“没有这个,待触发的错误会变成未捕获异常并让应用崩溃。” 十行把一次彻底的崩溃变成了一次安静的、可恢复的断线。
教训比修复更值钱: 每当你对一个也许还在拆除中的套接字或流调用 removeAllListeners() 时,先重新挂上一个空操作的 error 汇。一个未处理的 error 事件是一次崩溃,不是一行日志。
保持登录:提前刷新,也在被弹回时刷新
“保持连接”这一半有个“保持登录”的孪生兄弟。在这一组发布之前,GeekBye 的认证令牌就那么过期了 — 过一阵子,你会在工作流中途、看不出任何理由地,被不客气地扔回登录界面。
v1.6.8 从两个方向修复了这一点:
- 主动式: 一个定时器在令牌过期 之前 刷新它,安排在过期时刻减去一个聪明的缓冲(令牌寿命的一部分,设有下限和上限)。在你察觉之前你就已经被刷新了。
- 被动式: 如果一个请求还是返回
401— 时钟偏移、错过的定时器、一台刚醒的笔记本 — 客户端刷新一次,并把原始请求恰好重试一次。一个单一的守卫标志给这次重试设了上限,于是一个顽固的 401 永远不会打转成无限循环。
微妙却关键的部分:两条路径都在一个 互斥锁 之后。如果十个请求在同一瞬间撞上 401,它们不会触发十次刷新 — 它们全都等着那 一次 在途的刷新,然后重试。而当一次刷新确实无可挽救时,应用会 优雅地 让会话过期 — 一条清晰的十秒“您的会话已过期,请重新登录”提示 — 而不是无声地把你踢回登录界面。
重连:先加在客户端,然后移到后端
v1.6.9 让一次断掉的转录连接变得 可恢复 而非致命。当后端报告上游语音连接掉了时,客户端不再抛出致命错误,而是重连 — 带着刻意平静的体验:一条琥珀色的“正在重连…”提示(不是每次尝试一条),回来时一条绿色的“已重连”提示,而那条终结性的“请重启”错误 只在 每一次尝试都用尽 之后 才出现。
然后是这一组发布里我喜欢的一点:一个版本之后,在 v1.6.10 里,我们删掉了那段客户端侧的重连逻辑 — 大约 113 行 — 并把重连移到了后端,后端现在发出安静的“正在重连”状态消息,客户端只是照着反映。我们在客户端建起了恢复,判断后端才是正确的归属,然后把它搬了过去。这不是摇摆不定;这是一个难题诚实的生命周期。把可靠性逻辑劈在连接两端是一种坏味道 — 选一个归属方。(后端最终成了这件事的确定归属方,那正是 为什么你的 AI 记录助手在糟糕的 Wi-Fi 上会停下 里讲述的重连故事。)
同一个版本让录音时长限制更有人情味:不是一个生硬的错误,而是一条允许录音继续的警告提示,以及在它真的停下时一条清晰的“已达录音上限”消息 — 把友好的那句话露出来,把内部前缀剥掉。
一次重试统御一切
到了 v1.6.11,同一套带退避的重试逻辑已经被复制粘贴到了三个不同的地方 — 认证、更新、会话拆除。于是它被合并进了一个单一模块,带着一条关于 到底什么才值得重试 的共享规则:短暂的网络故障(连接重置、超时、DNS 打嗝、套接字挂断)以指数退避重试;真正的服务器错误则快速失败,因为重试一个货真价实的 4xx 只是在浪费用户的时间。一个分类器,几个具名预设,三个调用方迁移到它之上。
这一组发布教会了什么
- 一个未处理的
error事件是一次崩溃,不是一行日志。 Node 会重新抛出没有监听器的 error 事件。在一个你也许还在拆除的套接字上剥掉监听器,你就必须重新挂上一个空操作的 error 汇 — 否则一次掉线会把整个应用一起带走。 - 既在过期之前刷新凭据,也 在遇到 401 时被动刷新 — 两者都加上互斥锁。 主动式让你保持登录;被动式接住边缘情况;互斥锁意味着 N 个同时的调用方恰好造成一次刷新,而重试上限意味着一个坏令牌不会永远打转。
- 只在短暂错误时重试,并把分类器集中起来。 “这值得重试吗?”是一个决定,它属于一个地方。重试一个真正的错误只是一次更慢的失败。
- 决定谁拥有重连。 我们先把它加在客户端,再移到后端。恢复逻辑住在两端是一台 bug 生成器;给它一个单一的家。
这是那个终将成为 GeekBye v2 的可靠性故事的第三章。上一章,见 我们删掉了 5,000 行音频代码,转录开始重复出现(v1.6.0);重连最终落定之处,见 为什么你的 AI 记录助手在糟糕的 Wi-Fi 上会停下;而整条弧线,见 把软件打磨到完美地发布的解剖学。