故障档案

配置填好了,切个页面——没了

防抖保存依赖 300ms 计时器,cleanup 直接丢弃 pending 任务,导致页面切换或应用关闭时配置静默丢失。

矛盾的事实

用户在面板里填好 OpenAI 模型配置,等了一下,看起来 UI 响应了。关窗。重启面板。模型列表为空。配置消失得无声无息——没有错误提示、没有"保存失败"的警告、没有任何异常迹象。这个沉默正是最危险的地方。

让我查了客户的数据目录。

现象:从三个矛盾开始

SSH 进去查数据目录:

data\openclaw\openclaw.json                  1687B  mtime 2026/5/4 0:17:00
  顶层字段: gateway, plugins, tools          ← 缺 models / agents / skills

data\openclaw\openclaw.json.bak              无 models 字段
data\openclaw\agents\                        目录完全不存在

.bak 的修改时间停在 2026-05-01 20:33:42——面板首次启动时 calibration baseline 的时刻。之后三天,面板被打开过好多次,但这个文件的 mtime 一动不动。

gateway.log 里什么都有:

2026-05-03 23:56:51  [reload] config change detected (models.providers.openai.models)
2026-05-03 23:56:51  [reload] config hot reload applied (models.providers.openai.models)
2026-05-04 00:16:40  [reload] config change detected (models)
2026-05-04 00:16:40  [reload] config hot reload applied (models)

日志明确说配置被加载过。那为什么备份文件从不更新?只有一个解释:write_openclaw_config 几乎没被成功调用过。不是"保存成功了又被擦除",而是"压根没写盘"。

排查路线

对比所有备份版本(.bak.pre-portable-repair.bak)与 USB 缓存,全部缺 models 字段。如果真是"曾保存过被清掉",至少某个备份该有完整字段。结论:从未成功写入过。

这排除了"后端某个清理逻辑删了配置"的假设。

让客户在面板重新配置一个 OpenAI 模型,明确点击保存。立即看磁盘:

data\openclaw\openclaw.json  ← 立即写入,mtime 更新
agents\main\agent\          ← 目录被创建
gateway.log:  [reload] config hot reload applied (models)  ← 即时加载

写盘链路本身完全正常。问题不在落盘机制,在触发时机。

grep writeOpenclawConfig,找到 src/pages/models.js。整个页面没有显式"保存"按钮。用户所有的配置变化都依赖 onChange / onInput 的防抖自动保存 autoSave

打开 router 导航逻辑,看 cleanup 怎么被调用。这才是关键。

根因链路

前端 autoSave 的实现 (src/pages/models.js 第 634-637 行):

let _saveTimer = null

function autoSave(state) {
  clearTimeout(_saveTimer)
  _saveTimer = setTimeout(() => doAutoSave(state), 300)  // 防抖 300ms
}

每次字段变化都重置计时器,300ms 无新变化时才执行 doAutoSave。这个设计本身无问题,问题在 cleanup:

export function cleanup() {
  clearTimeout(_saveTimer)                    // ← 直接丢弃,无 flush
  _saveTimer = null
  // ...
}

当用户导航离开本页面时,cleanup() 被同步调用,计时器被 clearTimeout。如果此时 pending 的 setTimeout 还没来得及触发(即用户改字段后不足 300ms),那个写盘操作就永远不会发生。

路由切换时的调度 (src/router.js 第 49-52 行):

if (_currentCleanup) {
  try { _currentCleanup() } catch (_) {}     // 同步调用,无 await 语义
  _currentCleanup = null
}

navigate() 本身是 async,但这里对 cleanup 的调用是同步的。cleanup 返回 Promise 也没人 await,pending 的写盘任务就被留在半空。

Tauri 侧的应用关闭 (src-tauri/src/lib.rs 第 281-298 行):

  • 便携版:直接 std::process::exit(0),无任何 flush 通知前端
  • 标准版window.hide() 隐藏到托盘,计时器理论上还在,但用户已认为应用关闭

三种典型触发场景

动作结果
填完字段后 < 300ms 切到其他页面router cleanup → clearTimeout → setTimeout 永不执行
填完字段后 < 300ms 关闭面板(便携版)进程立即退出,setTimeout 无机会运行
填完字段后 < 300ms 切其他模型 provider 再切回同一计时器可能被新 state 覆盖

UX 层的雪上加霜

页面无显式保存按钮、无"已保存"角标、无 dirty 指示、关窗不弹确认对话。用户填完字段看到 UI 响应,主观认为"配置已生效"。等到进程重启读 openclaw.json 才发现一切归零——这时已太晚。

为什么 .bak 的 mtime 停在初始化时刻

calibration baseline 的同步代码走的是 fs::write 直接写入路径,UI 阻塞,必然落盘成功。之后每次用户修改配置全走 autoSave 防抖。只要 300ms 内导航走,doAutoSave 的调用链路就不会触发,write_openclaw_config 从不被调用,fs::copy(&path, &bak) 也不会执行。备份文件 mtime 永久停留在 5/1 20:33:42,反向锁死了根因。

扩散范围

grep 扫描所有页面的 writeOpenclawConfig_saveTimer 模式,确认下列页面均受同一缺陷影响:

  • src/pages/models.js — 现场客户触发的主页面
  • src/pages/gateway.js 第 296 行
  • src/pages/cron.js 第 91 行
  • src/pages/communication.js 第 71 行
  • src/pages/services.js 第 871 行
  • src/pages/assistant.js — 多处 saveConfig() 调用

至少 5 个以上的配置页面都有相同的 300ms 防抖 + 无 flush cleanup 问题。

修复方案

P0 — cleanup 改造成 async + flush 待命 (src/pages/models.js 第 628 行):

需要先把 state 提升到模块级变量,使 cleanup 能访问:

let _lastState = null
let _saveTimer = null

function autoSave(state) {
  _lastState = state                                    // 保存当前 state
  clearTimeout(_saveTimer)
  _saveTimer = setTimeout(() => doAutoSave(state), 300)
}

export async function cleanup() {
  if (_saveTimer) {
    clearTimeout(_saveTimer)
    _saveTimer = null
    try {
      await doAutoSave(_lastState)                      // flush pending 写盘
    } catch (e) {
      console.error('[models] flush on cleanup failed:', e)
    }
  }
  if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }
  cancelPendingRestart()
}

其他 5 个页面(gateway.js / cron.js / communication.js / services.js / assistant.js)同样改造。

P0 — router 导航改成 await cleanup (src/router.js 第 49-52 行):

if (_currentCleanup) {
  try {
    await _currentCleanup()                             // await 而非直接调
  } catch (_) {}
  _currentCleanup = null
}

navigate 函数本身已是 async,加 await 不影响外部 API 的同步表现。

P0 — Tauri CloseRequested 拦截 flush (src-tauri/src/lib.rs 第 281-298 行):

CloseRequested => {
  match config.mode {
    "portable" => {
      window.emit("app:will-close", ()).ok();          // 通知前端开始 flush
      // 前端监听此事件,同步执行 doAutoSave,完成后调用 confirm_close
      // Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit
    }
    "standard" => {
      // 同样 flush,再 hide 到托盘
      window.emit("app:will-close", ()).ok();
      window.hide().ok();
    }
  }
}

前端需要监听 app:will-close 事件,若有 pending autoSave 立即同步执行 doAutoSave(),完成或超时后调用 invoke('confirm_close')。Rust 侧收到确认或等待超时(5 秒兜底)后才执行 exit

P1 — UI 反馈层面(不紧急但重要):

  • 模型页右上角加状态角标:"已保存" / "保存中" / "未保存",来源于 _saveTimer != nulldoAutoSave 的 Promise 状态
  • 模型页加显式"保存"按钮(虽然 autoSave 已覆盖大多数场景,但用户心理预期需要这个 button)
  • doAutoSave 写盘成功立即吐 toast "配置已保存"(无需等 gateway 重启),gateway 重启后再吐 "配置已应用"

P2 — 全局 beforeunload 兜底

window.addEventListener('beforeunload', () => {
  if (_saveTimer) doAutoSave(_lastState)                // 同步 flush
})

浏览器/WebView 异常关闭或系统强制杀进程时的最后防线。

复现与缓解

修复发布前,客户想保护数据可以这样做:

  1. 改完字段后等待 1 秒,看右下角是否吐出 toast
  2. 或填完后点页面空白处,再等 1 秒再切走/关闭

便携版用户一个额外细节:即便这个 bug 修好了,关闭 panel 后仍需等 2 分钟让 guardian 完成到 USB 的反向同步,才能安心重启。

防回归措施

当前素材里尚无已实施的防回归。需要的包括:

  • 单元测试:快速导航场景,验证 cleanup 能 flush 待命任务
  • E2E 测试:填字段立即切页/关窗,确保落盘
  • 构建检查:扫描所有 cleanup,确保返回 Promise 且被正确 await
  • CI 巡检:定期测试便携版快速改配置并重启

修复前复现(任意模式):启动 panel → 进模型页 → 添加 provider → 填 baseURL + apiKey + 选模型 → 最后一字段后 100ms 内立即切侧边栏到"对话" → 关闭面板 → 重启。修复前模型列表空,修复后保留。

关联问题

素材中记录了另一个独立根因:便携启动序列 Sync-UsbToLocal 时把 cache 新版覆盖回 USB 旧版。那个 bug(FA-003)的症状也是"重启配置消失",但根因完全不同——启动脚本的同步顺序。

本 bug(FA-004)也导致配置消失,但根因是前端写盘没被触发。两个独立问题,在便携模式下症状叠加:修好这个 bug 的 P0(flush 成功)后,用户还需等 guardian 完成 120 秒间隔的反向同步到 USB,否则 2 分钟内重启 panel 仍可能被 USB 旧版本回滚。便携版用户两个修复都需要。

深层教训

静默失败比报错更危险——用户会继续信任 UI,不知道磁盘没落盘。防抖保存必须满足三个条件:cleanup 必须 flush(不能丢待命任务)、应用关闭必须被拦截并等待 flush、保存状态必须对用户可见。缺一,就出现"一切看起来都对,但数据其实没保存"的局面。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。