`/health` 语义收敛与 Windows 内嵌核心单实例

December 18, 2025
3 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

一、背景

  • 之前的 /health 同时承担“核心是否在线”和“S3 是否可用”的责任,逻辑偏重 S3,导致在 S3 很慢或超时时,客户端会把“核心还在工作但 S3 掉了”误判成“核心未启动”。
  • Windows 桌面端在 core_mode = embedded 时,通过 Process.start 启动 e2eepan-core.exe。Flutter 调试停止后,核心进程仍然在跑,下一次调试:
    • 旧核心继续占着端口。
    • 新启动的核心起不来。
    • 客户端连到的是旧核心,调试体验非常混乱。

这一笔记记录本轮针对“健康检查语义”和“Windows 内嵌核心生命周期”的收敛方案。


二、健康检查:拆分 /health/core/health/storage

1. 服务端接口拆分

  • 路由注册位置:core/internal/api/server.go:122-133
    • GET /health/corecoreHealth
    • GET /health/storagestorageHealth
  • coreHealth
    • 只代表“核心 HTTP 服务在线”,不依赖 S3。
    • 实现:core/internal/api/server.go:206-212
    • 返回结构:
      • status: "ok"
      • component: "core"
      • timestamp: <当前时间戳>
  • storageHealth
    • 原来的 healthCheck 重命名而来,专门检测 S3。
    • 实现:core/internal/api/server.go:214-233
    • 行为:
      • 使用 2 秒超时调用 s.s3.Ping
      • 成功时返回:
        • {"status":"ok","timestamp":...}
      • 失败时返回:
        • {"status":"error","component":"s3","code":"S3_UNAVAILABLE","error":"..."}

现在没有 /health 这个混合语义接口了,所有健康检查都必须明确是“core”还是“storage”。

2. 客户端 ApiClient 语义统一

位置:client/lib/core/api/api_client.dart:86-157

  • 新增/调整的接口:
    • Future<Map<String, dynamic>?> getCoreHealthDetail()
      • 调用 /health/core
      • 成功时返回 JSON;无法连接或无有效 JSON 时返回 null
    • Future<Map<String, dynamic>?> getStorageHealthDetail()
      • 原来的 getHealthDetail 改名而来,调用 /health/storage
    • Future<bool> healthCheck()
      • 不再绑定某个具体路径,而是一个聚合语义:
        • 核心在线且存储正常才返回 true
        • 具体逻辑:
          • getCoreHealthDetail() 为空或 status != "ok"false
          • 再看 getStorageHealthDetail()
            • 为空 → false
            • status == "ok"true,否则 false

这样,healthCheck() 明确表示“整体处于可工作的在线状态”,而不是模糊的“某个 /health 200 就算好”。

3. AppState 使用方式

位置:client/lib/core/state/app_state.dart

  • 启动时的初始网络检查:_checkInitialNetworkStatus :428-467
    • 每次尝试:
      • 先看 coreDetail = getCoreHealthDetail()
        • coreOk = coreDetail != null && coreDetail["status"] == "ok"
      • 再看 storageDetail = getStorageHealthDetail()
        • 如果 storageDetail["status"] == "error",提取 code 填到 _healthErrorCode
      • coreOk
        • 认为核心在线:_isOffline = false
        • 加载元数据:await _loadMetadata()
      • 否则重试,最后失败时降级为离线加载本地缓存。
  • 心跳检查:refreshHealthStatus:493-517
    • 同样是以 /health/core 决定 _isOffline,以 /health/storage 决定 _healthErrorCode
  • 调试接口:
    • debugCheckCoreStatus 中的 api.healthCheck() 现在等价于“核心在线且 S3 正常”,和 UI 含义一致。

效果:

  • “内核是否在线”不再被 S3 拖累,只看 /health/core
  • “S3 是否出问题”有明确的 code(如 S3_UNAVAILABLE),UI 可以独立渲染 S3 错误,而不误报“内核未启动”。

三、Windows 内嵌核心:通过锁文件保证单实例

1. 问题

  • Windows + 内嵌核心模式(core_mode = embedded 且平台为 Windows)下,AppState 通过 Process.start 拉起 e2eepan-core.exe
    • 路径约定:与 Flutter exe 同目录,由 run_windows.bat 预先编译就位。
  • Flutter 调试停止时,只杀掉了 Flutter 进程,e2eepan-core.exe 仍然存活:
    • 占用着原来的 HTTP 端口。
    • 下次调试:
      • 新核心起不来(端口被占)。
      • 客户端连到的是旧核心。
      • 导致“明明刚刚改了代码,但 UI 行为还是上一版”的错觉。

2. 解决方案:系统临时目录中的 PID 锁

位置:client/lib/core/state/app_state.dart:256-330

核心思路:在系统临时目录写一个锁文件,保存当前内嵌核心的 PID;每次尝试启动新核心前,先用这个 PID 把旧核心清掉。

2.1 锁文件路径与格式

  • 辅助函数:_desktopCoreLockFile() :256-264
    • 使用 Directory.systemTemp 获取系统临时目录。
    • 文件名固定为:e2eepan-desktop-core.lock
    • 内容:一个整数 PID 字符串,例如 12345

2.2 启动前清理旧核心

  • _startDesktopCoreProcess 中,真正启动前新增逻辑:client/lib/core/state/app_state.dart:264-285
    • 如果锁文件存在:
      • 读取内容并 int.tryParsepid
      • 若 PID 合法,则调用 Process.killPid(pid) 尝试杀掉旧的 e2eepan-core.exe
      • 等待约 200ms,让系统释放端口。
    • 即使杀失败(进程已退出等),最多打印一条 debug 日志,不会影响后续启动。

2.3 启动成功后写入新 PID

  • 仍在 _startDesktopCoreProcess 内:client/lib/core/state/app_state.dart:306-316
    • 启动成功后,将 _desktopCoreProcess.pid 写入锁文件:
      • 使用 writeAsStringflush: true,保证 PID 立即落盘。
    • 无论 suppressError 为 true 还是 false,只要成功启动都会更新锁。

2.4 正常停止时删除锁

  • _stopDesktopCore 中同步清理锁:client/lib/core/state/app_state.dart:318-330
    • p.kill()finally 中:
      • _desktopCoreProcess 置为 null
      • 如果锁文件存在,则删除。

3. 效果与约束

  • Debug 场景:
    • 第一次 flutter run -d windows
      • 核心被拉起,锁文件写入当前 PID。
    • 停止调试:
      • 核心生存,锁文件还在。
    • 再次 flutter run -d windows
      • 新进程启动时先用锁文件里的 PID 干掉旧核心,再启动新核心并更新锁。
    • 不会再出现“旧核心占着端口,新核心起不来”的情况。
  • 外部核心场景:
    • 只要 core_mode = externalAppState 完全不会调用 _startDesktopCoreProcess,也就不会创建或修改锁文件。
    • 这种模式下,外部核心完全由自己管理。

四、小结

  • /health 被拆分为 /health/core/health/storage,前端通过 getCoreHealthDetailgetStorageHealthDetail 显式区分“核心在线”与“S3 正常”,避免了状态语义混乱。
  • Windows 内嵌核心通过系统临时目录里的 PID 锁文件实现单实例控制:
    • 每次新调试启动前主动清理旧核心。
    • 正常停止时清理锁。
    • 极大改善了桌面端调试时“连接到旧核心”的困惑体验。