文档导航
文档 / Gateway Startup Locking Spec

Gateway Startup Locking Spec

Date: 2026-06-27 Status: Draft

Problem

当初始进程被中断,或在第一个进程仍处于 schema 迁移过程中又启动了第二个 gateway 进程时,桌面端的首次启动可能会导致捆绑的 gateway 无法启动。

观察到的失败模式如下:

  • 全新的桌面状态能够成功启动到足以初始化 agent workspace 的程度。
  • 第一个捆绑的 gateway 进程未能到达 gateway.started
  • 之后的 gateway 进程在 apply_pending() 中因 yoyo 的 LockTimeout 而失败。
  • yoyo 错误在每次重试时都报告同一个较早的进程 id。

直接的失败原因是桌面端 sessions.db 的 yoyo 迁移锁表中存在一条陈旧或仍处于活动状态的记录。 一旦出现这种状态,桌面端反复重试无法自行恢复。

Goals

  • 防止 Electron shell 为同一个桌面 profile 启动重复的 gateway 进程。
  • 在整个 server 生命周期内保持 gateway 的 pid 锁有效,并在优雅关闭时释放它。
  • 仅当记录的 pid 被证实已死亡时,才从陈旧的 yoyo 迁移锁中恢复。
  • 当另一个进程可能仍在迁移时,保留 yoyo 的安全保证。
  • 呈现可操作的启动错误,而不是难以理解的 PyInstaller traceback。

Non-Goals

  • 不要禁用 yoyo 迁移锁。
  • 不要在每次迁移锁超时时盲目运行 break-lock
  • 不要在此修复中更改 session 数据库 schema。
  • 不要让桌面端 gateway 与 Electron 共享进程。
  • 不要放宽 gateway 的绑定或 auth 行为。

Existing Surfaces

  • Electron 在 desktop/electron/src/main.ts 中通过 spawn(...) 启动捆绑的 gateway。
  • Electron 在报告 gateway 未变为健康状态之前,最多等待 45 秒的 /healthz
  • gateway 在 build_services() 之前以及 session 数据库迁移之前获取 GatewayPidLock
  • build_services() 在打开 SessionStorage 之前运行 apply_pending(session_db_path, migrations_dir)
  • apply_pending() 通过 with backend.lock(): 委托加锁。
  • yoyo 将迁移锁实现为 yoyo_lock 中的一行(以 pid 为键),并在 finally 代码块中将其移除。进程的硬退出可能会残留该行记录。

Design

实现三层防御。每一层针对不同的失败模式,并且应当能够独立测试。

Layer 1: Electron Single Instance Guard

在 Electron 应用启动附近、任何 gateway 启动工作能够运行之前,添加 app.requestSingleInstanceLock()

预期行为:

  • 如果无法获取锁,则新的 Electron 进程立即退出。
  • second-instance 时,已有的主窗口被恢复并获得焦点。
  • bootDesktopApp() 不得在第二个 Electron 进程中运行。
  • 已有进程仍然是唯一被允许 spawn gateway 的所有者。

这能减少因双击应用、从 Finder 重新打开,或在首次运行仍在进行时从 DMG 启动而导致的 重复 gateway 启动。

Layer 2: Gateway PID Lock Lifetime

将获取到的 GatewayPidLock 存储在返回的 GatewayServer 对象上,并在 GatewayServer.close() 中释放它。

预期行为:

  • start_gateway_server() 像现在一样,在数据库迁移之前获取锁。
  • 锁对象在整个 server 生命周期内保持强引用。
  • GatewayServer.close() 在关闭工作完成到新 gateway 可以安全启动的程度之后, 恰好调用一次 release()
  • release() 保持幂等。
  • 现有的 atexit 和信号清理仍作为尽力而为的回退路径保留。

这使得正常的 gateway 关闭变得显式,而不是仅依赖进程退出行为。

Layer 3: Conservative Yoyo Stale Lock Recovery

apply_pending() 内部包装 yoyo 的 LockTimeout 处理。

当 yoyo 报告锁超时时:

  1. 检查锁表中记录的 pid。
  2. 如果任何记录的 pid 仍存活,则在不清除锁的情况下失败。
  3. 如果记录的每个 pid 都已死亡或无效,则删除 yoyo 的锁记录行。
  4. 重试一次迁移。
  5. 如果重试失败,则在不进入另一个恢复循环的情况下呈现第二次失败。

存活性检查应使用与平台相适应的进程探测方式:

  • POSIX:os.kill(pid, 0)
  • Windows:OpenProcess 或等效的现有 helper 行为。

锁恢复路径必须记录结构化事件:

  • migrator.lock_timeout
  • migrator.lock_held_by_live_process
  • migrator.stale_lock_cleared
  • migrator.stale_lock_retry_failed

面向操作者的错误应说明 gateway 是仍在启动中、另一个 gateway 正在运行,还是有一个 陈旧的迁移锁无法恢复。

Safety Rules

  • 永远不要清除由存活 pid 持有的 yoyo 锁。
  • 如果无法安全地检查数据库,永远不要清除锁。
  • 在清除陈旧锁后,永远不要将迁移重试超过一次。
  • 当 schema 状态不确定时,保持迁移失败的明显性。
  • 宁可出现 false negative,也不要 false positive:让启动失败比破坏一次迁移更安全。

Implementation Notes

Electron:

  • desktop/electron/src/main.ts 中添加单实例处理。
  • 为已有进程保留当前的 startupInProgress guard。
  • 在第二个实例上,如果 mainWindow 存在,则恢复并聚焦它。

Gateway:

  • GatewayServer 扩展一个可选的 pid 锁字段。
  • GatewayPidLock.acquire() 成功后赋值获取到的锁。
  • GatewayServer.close() 中以 finally 安全的路径释放锁。

Migrator:

  • 显式导入 yoyo 的 LockTimeout
  • 添加一个小型 helper,通过 yoyo backend 或对同一本地数据库的独立 SQLite 连接来查询 yoyo_lock 行。
  • 添加一个小型 helper 来清除 yoyo 锁表。
  • 保持 :memory: 行为不变。
  • 保持正常的无待处理迁移行为不变。

Test Plan

Unit Tests

  • 当第一个锁仍被持有时,GatewayPidLock 拒绝对同一 state dir 的第二次锁获取。
  • GatewayServer.close() 释放存储的 gateway pid 锁。
  • apply_pending() 不会为存活 pid 清除 yoyo 锁。
  • apply_pending() 为已死亡 pid 清除 yoyo 锁并重试一次。
  • 在陈旧锁恢复失败后,apply_pending() 不会进入无界的重试循环。
  • apply_pending() 保持正常迁移成功和 no-op 行为。

Electron Tests

  • 第二个 Electron 实例不会调用 bootDesktopApp()
  • 第二个 Electron 实例聚焦已有窗口。
  • 当启动已在进行中时,桌面端重试不会 spawn 新的 gateway。

Integration Tests

  • 创建一个临时桌面状态,其 sessions.db 含有一条陈旧的 yoyo_lock 行。gateway 启动 将其清除并完成迁移。
  • 创建一个临时桌面状态,在 yoyo_lock 中记录一个存活的 helper 进程。gateway 启动以 清晰、无破坏性的错误失败。
  • 针对同一 state dir 启动两个 gateway 进程。第二个进程在数据库迁移之前失败。

Manual DMG Smoke

  • 将 DMG 安装到 /Applications
  • 从干净的桌面用户数据目录开始。
  • 在首次运行启动期间反复双击应用。
  • 确认只启动了一个 gateway 进程。
  • 确认应用变为就绪,或呈现一个清晰的单次启动错误。
  • 在首次运行迁移期间强制退出,重新启动,并确认当记录的 pid 不再存活时陈旧锁恢复有效。

Acceptance Criteria

  • 全新 DMG 的首次运行无法从两个 Electron 实例 spawn 两个被拥有的 gateway 进程。
  • 一个 pid 已死亡的陈旧 yoyo 迁移锁会被自动清除,且 gateway 启动成功。
  • 一个 pid 存活的 yoyo 迁移锁永远不会被自动清除。
  • 使用同一桌面状态的第二个 gateway 在迁移工作之前失败。
  • 优雅的 gateway 关闭会移除 gateway.pid 并释放 gateway.pid.lock
  • 失败信息区分活动启动、已运行的 gateway 和不可恢复的迁移锁状态。
  • CI 覆盖陈旧锁恢复和存活锁不恢复。

Rollout

  • 以正常启动行为发布,没有面向用户的设置项。
  • 在桌面端 gateway 日志中保留结构化日志,用于事后诊断。
  • 在该行为实现之后,添加一条简短的排障条目。

Open Questions

  • yoyo 锁检查应仅使用 yoyo backend API,还是对本地文件数据库使用直接 SQLite。
  • 桌面端启动在检测到 schema 迁移处于活动状态后,是否应延长首次运行的健康超时。
  • 启动 splash 是否应显示一个独立的 “Preparing database” 阶段。
在 GitHub 上编辑此页(英文原稿) OpenSquilla 文档 · 中文社区翻译