EDF 录制会话文件 flush 失败排查与修复
背景
睡眠监测应用在长时间 EDF 录制时,会每约 2 秒更新一次 edf_resume_session.json(记录 isRecording、lastWriteAtMs、EDF 路径等),供进程内崩溃恢复与 Native 关机 Job 判断「是否仍在监测」。
现场设备(全志平台 Android 10 定制机)出现:error.log 被同一错误刷满,且 crash_resume 把短中断误判为长达数小时的中断,最终自动补零并结束记录。
现象
1. error.log 单一错误刷屏
- 错误源:
EdfResumeSession._write - 异常:
FileSystemException: flush failed - 路径:外置目录下的
edf_resume_session.json(示例形态:/storage/emulated/0/Android/data/****/files/edf_resume_session.json) - 系统错误:
OS Error: I/O error, errno = 5(EIO) - 调用栈:
touchLastWrite→_write→RandomAccessFile.flush
统计示例(同一台机、同一晚):
| 日志文件 | 时间范围 | 错误条数 | 约间隔 |
|---|---|---|---|
| 某日 error.log | 20:27:29 → 23:59:59 | 4834 | ~2.6s/条 |
| 次日 error.log | 00:00:02 → 00:49:13 | 1121 | ~2.6s/条 |
与 home 里 _touchRecordingSessionIfNeeded 的 2 秒防抖一致,说明录制期间 大部分心跳写入在 flush 阶段失败。
2. 主 EDF 正常、仅 session 异常
operation.log 中:
- EDF 文件持续增大(例如 20MB → 56MB)
- 磁盘剩余约 26GB,非空间不足
- 每分钟「系统状态监控」里
session 中 lastWriteAtMs仍在前进
容易误判为「session 更新正常」。
3. 重启后 crash_resume 误判长中断
典型日志:
home crash_resume 预计:中断发生时间=2026-05-19 20:27:26 中断时长=16683 补0时长(s)=1800 补零后是否停止记录=true
home crash_resume fill_zero reached_limit stop_record_and_open_result
即:进程内曾看到较新的 lastWriteAtMs,但 磁盘上的 session 仍停在 20:27:26,重启后按 4.6 小时缺口 补 30 分钟零并结束记录。
影响链路
sequenceDiagram
participant EEG as 蓝牙波形写入
participant EDF as EDF 主文件(外置)
participant Session as edf_resume_session.json
participant Monitor as 每分钟监控读 session
participant Resume as crash_resume
EEG->>EDF: writeEdfData 正常
EEG->>Session: touchLastWrite 每 2s
Session--xSession: flush EIO (约 76% 失败)
Monitor->>Session: read 进程内可能读到未落盘数据
Note over Monitor: 监控显示 lastWrite 在走
Resume->>Session: 重启后 read 磁盘旧文件
Resume->>EDF: 按陈旧 lastWrite 补零/停录
| 能力 | 受影响方式 |
|---|---|
心跳 lastWriteAtMs |
磁盘时间戳冻结,崩溃恢复缺口计算错误 |
Native MonitoringSessionGuard |
读同一 JSON,可能认为「未在监测」或读到旧状态 |
| error.log | 数千条重复 I/O 错误,进一步加重存储压力 |
| 数据质量 | 误补大量零、二次结束同一条 EDF |
排查步骤(可复用)
步骤 1:收敛 error.log
# 统计错误来源(示例:PowerShell)
Select-String -Path error.log -Pattern '^\d{4}-\d{2}-\d{2}' |
ForEach-Object { if ($_.Line -match ' (.+)$') { $matches[1] } } |
Group-Object | Sort-Object Count -Descending
预期:若几乎 100% 为 EdfResumeSession._write + errno = 5,则聚焦 session 写路径,而非泛查网络/蓝牙。
步骤 2:对齐 operation.log 时间线
关键检索:
markRecordingStarted/监测开始系统状态监控中的session 中 lastWriteAtMs与EDF 文件大小home crash_resume/fill_zero/fill_zero reached_limitEdfResumeSession.stop skipped(debounce 导致 session 未删)
确认:EDF 增长是否正常、session 监控是否「假新鲜」、结束是补零超限还是其它 stopEndRecord。
步骤 3:对照 bugreport(可选)
- 应用是否前台/Home Launcher
- 是否有 ANR、存储满告警
MemTotal、外置存储挂载(FUSE/f2fs)
本次样例:无 ANR、磁盘充足,更像 外置目录 + 高频小文件 fsync 问题。
步骤 4:区分「进程内读到」与「落盘成功」
| 观测 | 含义 |
|---|---|
| 监控里 lastWrite 每分钟更新 | 同进程 read() 可能读到 page cache / 未 flush 内容 |
| 重启后 crash_resume 仍用几小时前的 lastWrite | 磁盘文件未 durable 更新 |
fill_zero_verify 里 session_touch_advanced=true 但随后仍误判 |
当次 touch 可能仅在内存侧可见 |
步骤 5:排除其它「自动结束」原因
若 crash_resume 显示 补零后是否停止记录=false 仍进入 RecordOKView,查 operation.log 是否出现:
fill_zero reached_limit(超 30 分钟缺口)CloseReconnectingEvent 30分钟倒计时RecordListView headChgState 1(录制中 GPIO 判充电 →stopEndRecord(3))Android设备没电停止
根因归纳
- 写路径:session 放在 外置应用目录(
getExternalStorageDirectory),与 EDF 大文件争抢 FUSE 层 I/O。 - 写方式:
FileMode.write截断重写 +flush()+flushSync()双显式刷盘,每 2 秒一次,失败点集中在flush(EIO)。 - 可观测性误导:失败时仍可能
read()到较新 JSON,监控日志不能代表落盘成功。 - 业务放大:
crash_resume仅依赖 session 的lastWriteAtMs计算缺口,未与 EDF mtime/大小交叉校验。
修复方案(已落地思路)
1. session 迁至内部存储
- Dart:
getApplicationSupportDirectory()(Android 对齐Context.getFilesDir()) - 路径形态:
/data/user/0/<包名>/files/edf_resume_session.json - EDF 仍写外置;仅元数据 JSON 进内部存储
Native 若存在 MonitoringSessionGuard 读 session,需同步改为 context.filesDir,与 Dart 一致。
2. 弱化 flush
原逻辑(问题版本):
raf = await file.open(mode: FileMode.write);
await raf.writeFrom(bytes);
await raf.flush();
raf.flushSync();
await raf.close();
现逻辑:
await file.writeAsBytes(utf8.encode(jsonEncode(data)));
writeAsBytes内部完成 打开 → 写入 → 关闭- 默认
flush: false,避免在外置存储上反复fsync;close时刷用户态缓冲,对 KB 级心跳通常足够 - 若仍偶发失败,可再评估
flush: true(一次)或「临时文件 + rename」原子写
3. 兼容旧外置 session
| API | 行为 |
|---|---|
read / stop |
先内部,再回退读/删外置 legacy 文件 |
start / _write |
只写内部;start 成功后尝试删除外置旧文件 |
4. 建议后续增强(未全部实现)
stopEndRecord打日志带form=原因码,便于区分充电/GPIO/补零超限- crash_resume 增加 EDF 文件 mtime/大小 与 session 交叉校验
- EIO 连续失败时 退避 + error 限流,避免 error.log 自我放大
验证建议
- 长时录制(>2h)观察 error.log:不应再出现高密度
EdfResumeSession._write+ EIO。 - 人为杀进程后重启:
crash_resume中断秒数应与真实空档一致(秒级/分钟级),而非小时级。 fill_zero_verify后对比 磁盘 session 的lastWriteAtMs(adb pull 内部 files 目录),与 operation 监控一致。- 关机 Job:有/无 session 时
session_file_missing/isMonitoringActive行为符合预期。
附录:与 session 无关的一次「自动结束」样例
某次日志:补零后是否停止记录=false 且 crash_resume success 后约 1s 出现 手动结束 + RecordOKView,同时有 headChgState 1。
对应代码:readGpio() 每 5s 检测,录制中 headChgState == 1(充电/入舱)会 stopEndRecord(3),日志文案仍为「手动结束」,易与真·用户点击混淆。
排查时务必看 GPIO/充电状态 与 stopEndRecord 的 form 原因,勿全部归因于 session。
相关代码位置
| 模块 | 文件 | 说明 |
|---|---|---|
| Session 读写 | lib/utils/EdfResumeSession.dart |
内部存储 + writeAsBytes |
| 心跳触发 | lib/home.dart |
_touchRecordingSessionIfNeeded |
| 崩溃恢复 | lib/home.dart |
_resumeEdfAfterCrashIfNeeded / _fillCrashResumeZeroIfNeeded |
| 录制开始写 session | lib/RecordDataView.dart |
EdfResumeSession.start |
| 充电自动停 | lib/home.dart |
readGpio → stopEndRecord(3) |
小结
| 项 | 结论 |
|---|---|
| 直接原因 | 外置存储上对 edf_resume_session.json 高频 双 flush 导致 EIO |
| 业务后果 | 磁盘 lastWriteAtMs 停滞 → crash_resume 误补零/停录 |
| 修复要点 | 内部存储 + 弱化 flush + legacy 路径兼容 |
| 排查关键 | 对比 error / operation / 重启后 session 磁盘内容,勿只看进程内监控 |