fix: 隧道断线自愈 + 修复配置负缓存永久固化(根治「隧道一断即整栈不可用 / 必须重启后端」) #21

Merged
yanhui merged 1 commits from codex/tunnel-selfheal-config-cache-0922 into main 2026-09-22 23:58:25 +08:00
Collaborator

目的与范围

远端 RST 会把系统库隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。
当前实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。

1. 隧道断了却拉着应用陪葬 —— scripts/start-remote.mjs

隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉。于是每一次 RST 都升级成
一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。

改为「退避重连隧道本身,后端/前端进程不受影响」—— 这正是 autossh 的语义
(autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数退避,
建立成功即重置退避。

顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s,
以便更快发现已被对端 RST 的死连接。

2. 配置负缓存被永久固化 —— backend/src/config/cloudDatabase.js

系统库读取失败时把「空候选」写进缓存,而入口判断是
if (cachedCloudConfigCandidates) —— [] 在 JS 里是 truthy,于是这次失败被永久固化:
之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用,必须重启后端。

改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整):
入口用 !== null 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。
缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清 TTL 时间戳
而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完配置点测试能立即看到真实结果。

顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂 20 分钟,
用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。

改动文件(4 个,+336 / -27):

文件 说明
scripts/start-remote.mjs 隧道自愈循环、退避函数、保活参数
backend/src/config/cloudDatabase.js 负缓存 TTL、失效单点、请求超时收敛
backend/test/cloudDatabaseConfigCache.test.js 新增:负缓存三段语义
test/remote-access.test.mjs 新增:保活参数、退避曲线、防腐化断言

同步与兼容性

  • 基于 origin/main 最新提交 4946018,无冲突。
  • 对外行为兼容:隧道端口号、连接配置格式、buildSshArgs 的其余参数均未变。
  • 环境变量新增可选 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS(不设则默认 30000ms)。
  • start-remote.mjs 的 main(mode) 对外签名未变;新增导出 computeTunnelRetryDelay 供测试。
  • 语义变化(有意为之):隧道断开不再导致进程退出,因此不再需要外部守护进程兜底;
    原 dev-supervisor.mjs 可退休(该文件在 .workbuddy/ 内,不受本次提交影响)。

验证

测试(均含对照组实验,证明用例真能拦住回归)

  • 新增 cloudDatabaseConfigCache.test.js,锁定负缓存三段语义:TTL 内不重复解析 /
    到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰。
    对照组:把失败态改回「永不过期」(即旧行为)→ 两条用例如实失败,
    报错正是线上症状「配置读取应已恢复,不该再报业务数据库尚未配置」。
  • test/remote-access.test.mjs 补隧道断言。对照组:把 stopChildren() 加回自愈循环 →
    「防腐化」用例立即抓住。7/7 通过。
  • 后端全量 node --test test/*.test.js:340 项 / 339 通过,唯一失败为
    resilientSecretStore 的 Windows 权限断言(0o600 vs 实际 0o666,环境固有,与本次无关)。
  • node scripts/check-syntax.js 通过(151 个文件)。

本地实测(真实断线演练)

指标 杀隧道前 杀隧道 6 秒后
隧道进程 PID 36268 15816(自动重建)
后端进程 PID 29580 29580(完全未动)
/health 200 200

隧道 1 秒内自愈,应用毫发无伤。对照旧行为,这里会是「整树退出 + 等守护 2 分钟重启」。

风险与回退

  • 风险:若对端是永久不可达(配置被吊销等),隧道将无限退避重连而不再让进程退出,
    表面看是「进程还活着但不工作」。缓解:失败分支持续打印
    SSH 隧道建立失败(…),<n>ms 后重试,日志可判别;退避封顶 30s 不会打爆对端。
  • 风险:负缓存 TTL 生效期间(默认 30s)配置解析失败会快速返回 500,属预期;
    比原「永久 500 直到重启」是严格改善。
  • 回退:改动集中在 4 个文件,git revert 单个提交即可完全回退,无数据迁移、
    无 schema 变化、无持久化状态。

待确认

  • 请求超时从 10 分钟收敛到 60s:本页最重的冷查实测 16~42s。若线上存在更慢的报表查询,
    可能需要单独放宽该接口的 options.requestTimeout,请评审时确认。
## 目的与范围 远端 RST 会把系统库隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。 当前实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。 ### 1. 隧道断了却拉着应用陪葬 —— `scripts/start-remote.mjs` 隧道进程 `exit` 时直接 `stopChildren()`,把后端和前端一起杀掉。于是每一次 RST 都升级成 一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。 改为「**退避重连隧道本身,后端/前端进程不受影响**」—— 这正是 autossh 的语义 (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数退避, 建立成功即重置退避。 顺带补齐保活参数:`TCPKeepAlive=yes`、`ConnectTimeout=10`,探活间隔 30s → 15s, 以便更快发现已被对端 RST 的死连接。 ### 2. 配置负缓存被永久固化 —— `backend/src/config/cloudDatabase.js` 系统库读取失败时把「空候选」写进缓存,而入口判断是 `if (cachedCloudConfigCandidates)` —— `[]` 在 JS 里是 truthy,于是这次失败被**永久固化**: 之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用,**必须重启后端**。 改为「**带 TTL 的负缓存**」(默认 30s,可用 `CLOUD_DB_CONFIG_NEGATIVE_TTL_MS` 调整): 入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。 缓存清理收敛到 `invalidateCloudConfigCache()` 单点,避免日后只清数组、忘了清 TTL 时间戳 而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完配置点测试能立即看到真实结果。 顺带把请求超时默认值 `600000ms → 60000ms`:10 分钟 × `maxAttempts=2` 最坏挂 20 分钟, 用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。 改动文件(4 个,+336 / -27): | 文件 | 说明 | |---|---| | `scripts/start-remote.mjs` | 隧道自愈循环、退避函数、保活参数 | | `backend/src/config/cloudDatabase.js` | 负缓存 TTL、失效单点、请求超时收敛 | | `backend/test/cloudDatabaseConfigCache.test.js` | 新增:负缓存三段语义 | | `test/remote-access.test.mjs` | 新增:保活参数、退避曲线、防腐化断言 | ## 同步与兼容性 - 基于 `origin/main` 最新提交 `4946018`,无冲突。 - 对外行为兼容:隧道端口号、连接配置格式、`buildSshArgs` 的其余参数均未变。 - 环境变量新增可选 `CLOUD_DB_CONFIG_NEGATIVE_TTL_MS`(不设则默认 30000ms)。 - `start-remote.mjs` 的 `main(mode)` 对外签名未变;新增导出 `computeTunnelRetryDelay` 供测试。 - 语义变化(有意为之):隧道断开不再导致进程退出,因此**不再需要外部守护进程兜底**; 原 `dev-supervisor.mjs` 可退休(该文件在 `.workbuddy/` 内,不受本次提交影响)。 ## 验证 **测试**(均含对照组实验,证明用例真能拦住回归) - 新增 `cloudDatabaseConfigCache.test.js`,锁定负缓存三段语义:TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰。 对照组:把失败态改回「永不过期」(即旧行为)→ 两条用例如实失败, 报错正是线上症状「配置读取应已恢复,不该再报业务数据库尚未配置」。 - `test/remote-access.test.mjs` 补隧道断言。对照组:把 `stopChildren()` 加回自愈循环 → 「防腐化」用例立即抓住。7/7 通过。 - 后端全量 `node --test test/*.test.js`:**340 项 / 339 通过**,唯一失败为 `resilientSecretStore` 的 Windows 权限断言(`0o600` vs 实际 `0o666`,环境固有,与本次无关)。 - `node scripts/check-syntax.js` 通过(151 个文件)。 **本地实测(真实断线演练)** | 指标 | 杀隧道前 | 杀隧道 6 秒后 | |---|---|---| | 隧道进程 PID | 36268 | **15816**(自动重建) | | 后端进程 PID | 29580 | **29580**(完全未动) | | `/health` | 200 | **200** | 隧道 1 秒内自愈,应用毫发无伤。对照旧行为,这里会是「整树退出 + 等守护 2 分钟重启」。 ## 风险与回退 - **风险**:若对端是**永久**不可达(配置被吊销等),隧道将无限退避重连而不再让进程退出, 表面看是「进程还活着但不工作」。缓解:失败分支持续打印 `SSH 隧道建立失败(…),<n>ms 后重试`,日志可判别;退避封顶 30s 不会打爆对端。 - **风险**:负缓存 TTL 生效期间(默认 30s)配置解析失败会快速返回 500,属预期; 比原「永久 500 直到重启」是严格改善。 - **回退**:改动集中在 4 个文件,`git revert` 单个提交即可完全回退,无数据迁移、 无 schema 变化、无持久化状态。 ## 待确认 - 请求超时从 10 分钟收敛到 60s:本页最重的冷查实测 16~42s。若线上存在更慢的报表查询, 可能需要单独放宽该接口的 `options.requestTimeout`,请评审时确认。
yanhui added 1 commit 2026-09-22 19:33:27 +08:00
远端 RST 会把隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。原先的
实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。

1) scripts/start-remote.mjs —— 隧道断了却拉着应用陪葬

   隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉,于是每一次 RST 都
   升级成一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。

   改为「退避重连隧道本身,后端/前端进程不受影响」——这正是 autossh 的语义
   (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数
   退避,建立成功即重置退避。

   顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s,
   以便更快发现已被对端 RST 的死连接。

2) backend/src/config/cloudDatabase.js —— 配置负缓存被永久固化

   系统库读取失败时把「空候选」写进缓存,而入口判断是
   `if (cachedCloudConfigCandidates)` —— [] 在 JS 里是 truthy,于是这次失败被
   永久固化:之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用,
   必须重启后端。

   改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整):
   入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。
   缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清
   TTL 时间戳而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完
   配置点测试能立即看到真实结果。

   顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂
   20 分钟,用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。

验证:
- 新增 backend/test/cloudDatabaseConfigCache.test.js,锁定负缓存三段语义
  (TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰);
  test/remote-access.test.mjs 补隧道保活参数、退避曲线,以及「自愈循环不得调用
  stopChildren」的防腐化断言。
- 两处均做对照组实验:去掉保护后测试如实报错(负缓存 → 报 TTL 到期未重试;
  隧道 → 把 stopChildren 加回去即被用例抓住),证明用例真能拦住回归。
- 后端全量 340 项 / 339 通过,唯一失败为 resilientSecretStore 的 Windows 权限
  断言(环境固有,与本次无关);check-syntax 通过。
- 本地实测:kill 掉隧道进程后 1 秒内自动重建(PID 36268 → 15816),后端进程
  PID 未变(29580)、/health 仍返回 200。
yanhui merged commit f4303c0aed into main 2026-09-22 23:58:25 +08:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lunanting/cangku#21
No description provided.