返回博客

Tauri Sidecar 与服务端双写竞态:旧二进制覆盖新代码的 90 分钟血案

我在一个 Tauri 桌面应用里加了一个工作流功能。服务端刚修完一个 bug,部署上线,md5 也对了,PM2 也重启了——但 DB 里某条 nodes_state.error 的文本,还是改动之前的老版本。我连着加了四五次 debug tag,每次部署完查数据库,依然是那条"不该出现"的旧文案,一个 [v18 pid=...] 的 tag 都没有。

这种感觉很熟悉:代码明明在那里,就是不执行。

现象

  • 服务端源文件 grep 可以找到新 throw 文本,md5 与 git HEAD 一致
  • PM2 cwd 指向新 release 目录,tsx ESM 编译缓存清了又清
  • 服务端诊断日志(写 /tmp/*.log从不生成——catch 块从未触发
  • DB 里同一条 nodes_state 行:finished_at 有值,started_at 为 NULL
  • runs.error 文本是上上版本的 throw,没有任何新加的 debug 标记

根因

这个 Tauri 应用同时存在两条执行路径:

  1. 服务端 inline runnerPOST /api/runs 触发,走 FORCE_INLINE_WORKFLOWS 白名单,服务端进程内直接执行
  2. 客户端 sidecar runner:前端编辑器检测到桌面 UA,无条件调用 triggerSidecarWorkflow(),sidecar 本地执行后通过 PUT /api/runs/<id>/nodes/<id>/finished 把结果上报给服务端

两条路径拿到同一个 run_id并发执行

关键不对称:sidecar 是已经打包的二进制。服务端新部署了十次,sidecar 里的代码还是打包时的版本。服务端修复的 bootstrap 注入、新的 throw 文本——sidecar 一概不知,它用自己的旧逻辑跑,失败后用 HTTP PUT 把旧错误文案写进 DB。

服务端 runner 有时根本没来得及写(被 CAS/乐观锁或 mutex 挡住),有时写了也被 sidecar 的 PUT 覆盖,因为 sidecar 的 PUT 是直接写,不经过服务端的状态机。

坑:服务端 runner 在跑,但 DB 是 sidecar 写的

你花了 90 分钟追服务端:清 tsx 缓存、重启 PM2、对比 md5、在 catch 里写临时文件……每一步都确认了服务端部署正确。但 catch 块永远不触发,因为服务端的 throw 根本没机会执行——同一个 run_id 已经被 sidecar 抢先用旧代码跑完,然后通过 HTTP PUT 写入 DB 了。

nodes_state.started_at = NULLfinished_at 有值,是最直接的信号:两个写入者的 patch shape 不同,服务端 patchNode 会同时写 startedAt,sidecar 的 PUT /finished 不写。

正解

先查 nginx access log,找外来写入者

grep "<run_id>" /www/wwwlogs/<site>.log | grep "PUT.*nodes.*finished"

如果输出里的 User-Agent 包含 desktop/electron/tauri/ 字样——那就是 sidecar 在写,跟服务端无关。

找到病灶后,修复只需 5 行前端代码:在前端 handleRun 里加一个白名单,让服务端独占的工作流不走 sidecar 路径:

// Editor.tsx
const SERVER_ONLY_WORKFLOWS = new Set<string>(['my-server-only-workflow'])
 
if (isDesktop && !SERVER_ONLY_WORKFLOWS.has(workflowId)) {
  triggerSidecarWorkflow({ runId, ... }).catch(console.error)
}

前端 SPA 从服务端加载 HTML,这个 guard 部署后下次页面加载即生效,不需要让用户重装桌面应用。sidecar 二进制原封不动,但它不会再被调用处理服务端独占的工作流了。

正解:nginx log 是打破死循环的关键

不要在服务端一侧无限打 debug tag。当"已验证部署正确但 DB 仍是旧数据"时,立刻检查 nginx access log 里是否有同一 run_idPUT .../finished 来自桌面 UA。确认后,在前端加白名单 guard,让服务端独占的工作流绑过 sidecar 触发。

深度防御方向:服务端 PUT 端点可以拒绝 FORCE_INLINE 工作流的 sidecar 写入;sidecar 入口可以加 workflow ID 检查;CI 可以对 sidecar 二进制版本做最低版本校验。

排查时序(供参考)

  1. nodes_state.started_at = NULL + finished_at 有值 → 怀疑双写入者
  2. grep "<run_id>" nginx.log | grep "PUT.*finished" → 确认外来写入
  3. 查 User-Agent → 确认是桌面 sidecar
  4. 在 sidecar 源码里 grep 旧 throw 文本 → 确认二进制版本
  5. 前端加 SERVER_ONLY_WORKFLOWS guard → 5 行修复

一句话外卖

HTTP 是共享状态:客户端和服务端只要都能 PUT 同一个 DB 行,就会竞态——服务端的代码更新不会让已打包的桌面二进制自动失效;必须在写入点上明确划定唯一写入者。