EDF 录制会话文件 flush 失败排查与修复

背景

睡眠监测应用在长时间 EDF 录制时,会每约 2 秒更新一次 edf_resume_session.json(记录 isRecordinglastWriteAtMs、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_writeRandomAccessFile.flush

统计示例(同一台机、同一晚):

日志文件 时间范围 错误条数 约间隔
某日 error.log 20:27:29 → 23:59:59 4834 ~2.6s/条
次日 error.log 00:00:02 → 00:49:13 1121 ~2.6s/条

home_touchRecordingSessionIfNeeded2 秒防抖一致,说明录制期间 大部分心跳写入在 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 中 lastWriteAtMsEDF 文件大小
  • home crash_resume / fill_zero / fill_zero reached_limit
  • EdfResumeSession.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设备没电停止

根因归纳

  1. 写路径:session 放在 外置应用目录getExternalStorageDirectory),与 EDF 大文件争抢 FUSE 层 I/O。
  2. 写方式FileMode.write 截断重写 + flush() + flushSync() 双显式刷盘,每 2 秒一次,失败点集中在 flush(EIO)。
  3. 可观测性误导:失败时仍可能 read() 到较新 JSON,监控日志不能代表落盘成功
  4. 业务放大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,避免在外置存储上反复 fsyncclose 时刷用户态缓冲,对 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 自我放大

验证建议

  1. 长时录制(>2h)观察 error.log:不应再出现高密度 EdfResumeSession._write + EIO。
  2. 人为杀进程后重启:crash_resume 中断秒数应与真实空档一致(秒级/分钟级),而非小时级。
  3. fill_zero_verify 后对比 磁盘 session 的 lastWriteAtMs(adb pull 内部 files 目录),与 operation 监控一致。
  4. 关机 Job:有/无 session 时 session_file_missing / isMonitoringActive 行为符合预期。

附录:与 session 无关的一次「自动结束」样例

某次日志:补零后是否停止记录=falsecrash_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 readGpiostopEndRecord(3)

小结

结论
直接原因 外置存储上对 edf_resume_session.json 高频 双 flush 导致 EIO
业务后果 磁盘 lastWriteAtMs 停滞 → crash_resume 误补零/停录
修复要点 内部存储 + 弱化 flush + legacy 路径兼容
排查关键 对比 error / operation / 重启后 session 磁盘内容,勿只看进程内监控