一、背景
- 之前的
/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-133GET /health/core→coreHealthGET /health/storage→storageHealth
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":"..."}
- 使用 2 秒超时调用
- 原来的
现在没有
/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()明确表示“整体处于可工作的在线状态”,而不是模糊的“某个/health200 就算好”。
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 exe 同目录,由
- 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.tryParse为pid。 - 若 PID 合法,则调用
Process.killPid(pid)尝试杀掉旧的e2eepan-core.exe。 - 等待约 200ms,让系统释放端口。
- 读取内容并
- 即使杀失败(进程已退出等),最多打印一条 debug 日志,不会影响后续启动。
- 如果锁文件存在:
2.3 启动成功后写入新 PID
- 仍在
_startDesktopCoreProcess内:client/lib/core/state/app_state.dart:306-316- 启动成功后,将
_desktopCoreProcess.pid写入锁文件:- 使用
writeAsString并flush: 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 = external,AppState完全不会调用_startDesktopCoreProcess,也就不会创建或修改锁文件。 - 这种模式下,外部核心完全由自己管理。
- 只要
四、小结
/health被拆分为/health/core和/health/storage,前端通过getCoreHealthDetail和getStorageHealthDetail显式区分“核心在线”与“S3 正常”,避免了状态语义混乱。- Windows 内嵌核心通过系统临时目录里的 PID 锁文件实现单实例控制:
- 每次新调试启动前主动清理旧核心。
- 正常停止时清理锁。
- 极大改善了桌面端调试时“连接到旧核心”的困惑体验。