Files
dsh_shenxian/dsh-server-docs/04-调整方案/08-实例常驻上限与单活跃会话.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

6.4 KiB
Raw Blame History

08-实例常驻上限与单活跃会话(idle-reap + last-wins,2026-09-09 落地)

背景与动机

  • 平台:dshs(本地后端,单机)托管多租户 dsh 0.1.2-rc.1,每账号一个常驻 main 实例。
  • 资源约束(实测):机器总内存 1.87GB;单个 dsh web 实例 RSS≈187MB(约占整机 10%)。 现状"无限常驻 + 无回收"下,约 8~10 个并发账号即接近 OOM——多用户上线前必须先加常驻上限与回收。
  • 对话/工作区数据全部磁盘持久化($DSH_HOME/sessions/、storages/、ws/),回收进程不触碰数据, 冷启恢复后数据完整——这是回收机制成立的前提。

用户决策(2026-09-09)

  1. 确认实现:最大常驻数量 + 自动回收(默认 cap=4、空闲 TTL=7d、扫描 60s,均可 env 覆盖)。
  2. 同账号多设备 = 单活跃会话(last-wins):不支持多浏览器同时访问;只允许最后登录的浏览器访问, 先前登录的浏览器立即失效(API/子域 401),重新访问需再次登录。

实现(commit 928dde1,dshs)

A. 单活跃会话(last-wins)

文件 改动
src/web/routes/auth.ts /api/auth/login 在 createSession 前 deleteUserSessions(user.id)——新登录作废该账号全部旧 sid
  • 旧 sid 一旦删除,所有 requireAuth 路由与 proxy 子域鉴权(resolveSubdomainAccess 查 DB)立即返回 401。
  • 同一浏览器多标签页共享 cookie = 同一 sid,不受影响;按 sid 粒度判定,非按连接数。

B. 实例常驻上限 + 空闲回收(本地后端 idle-reap)

文件 改动
src/config.ts 新增 3 配置(默认值 + env):maxIdleInstances=4(0=禁用 cap)、instanceIdleTtlSeconds=604800(0=禁用 TTL)、idleReapIntervalSeconds=60
src/supervisor/spawner.ts Spawner 接口新增 touch(userId)(活跃信号)
src/supervisor/orchestrator.ts LocalSpawner:lastActive Map + touch();构造时按配置启 reaper 定时器(unref);reapOnce() 规则见下;stop() 清 lastActive;teardown() 清定时器
src/supervisor/proxy.ts resolveSubdomainAccess 鉴权通过后 touch(userId)(子域 HTTP + WS 两入口全覆盖);legacy /u/:slug/dsh 路由同步 touch
src/web/routes/dsh.ts /api/dsh/enter 开头 touch(userId)(进入工作区即活跃)
src/supervisor/k8s-spawner.ts touch no-op(k8s 后端走自身 reconcile Phase 4 会话回收,不依赖流量信号)

reapOnce 规则(每次 tick 按顺序执行):

  1. 空闲 TTL 回收:lastActive 距今 > TTL 的 main → stop();
  2. 数量上限(LRU):剩余常驻数 > cap → 按 lastActive 升序停掉差额;
  3. 每次回收在 stderr 打日志:[idle-reap] stop <userId> (原因; idle Ns)(journald 可见)。

活跃信号来源:proxy 真实流量(用户每次点击/WS 消息都会 touch)+ enter + launch/restartMain。

配置速查(env,前缀 DSHS_)

env 默认 说明
DSHS_MAX_IDLE_INSTANCES 4 常驻实例上限(0=不限制)
DSHS_INSTANCE_IDLE_TTL 604800(7 天) 无流量空闲多久回收(0=禁用)
DSHS_IDLE_REAP_INTERVAL 60 回收扫描周期(秒)

验证记录(2026-09-09 实测)

单活跃会话

login#1 → 200 sid1;login#2 → 200 sid2
me(sid1) = 401(被顶下线 ✓)  me(sid2) = 200(活跃 ✓)  → PASS

(throwaway 用户经真实 /api/auth/login 双登录验证,用完即删)

idle-reap(env 覆盖 TTL=20s / interval=5s 实测后还原)

enter admin → 冷启实例 pid 138036
[journald] [idle-reap] stop cce6d1cd... (idle>20s; idle 23s)   ← 到点回收
进程数 0;sessions/ 目录 2 条保留、portal-entry marker 保留      ← 数据零丢失
re-enter → 新实例拉起(port 35515)正常                          ← 冷启恢复
env 还原 → 0 覆盖残留(默认 cap=4 / ttl=7d / interval=60s)

各场景行为

场景 行为
用户每天活跃 常驻不回收,秒进
用户 >7 天不用 空闲回收(数据保留);重登冷启 5-6s
并发 >4 最早不活跃的被 LRU 回收(admin 长期不用同样会被回收)
回收瞬间用户点开 enter 冷启兜底,数据无损
同账号 2 台电脑 同一实例同一对话空间;后登录顶掉先登录(旧 sid 401)
崩溃中实例 不参与回收(崩溃重启 backoff 机制管)
门户服务重启 常驻实例全部消失(现状);数据在,用户访问自动冷启

事故记录:代理 keep-alive 半开池挂起(2026-09-09,commit 6336c53)

  • 现象:部署重启后用户访问 admin.dsh.alotbuy.com 一直加载;门户 3080 秒回、实例直连秒回,但走 proxy 转发的请求全部挂起(日志 req 只有 incoming 无 completed)。
  • 根因:proxy 进程(门户)的 HTTP keep-alive 连接池在实例多次重启(12:24→12:30→12:40)后残留指向已回收端口的半开连接;新请求复用到死 socket 时不触发 error(TCP 半开),只有 error 才走 retry → 永久挂起。恢复手段=重启门户进程清空池。
  • 治本:本地模式 proxy 转发每请求新连接(useKeepAlive=false,loopback 建连开销 ~0.01ms 可忽略),从机制上消除半开池;k8s 模式保留 keep-alive(Headless Service DNS 重解析语义不变)。
  • 验证:重启后 HTML 经 proxy 200 @17ms,连续 5 次全 200 @~10ms;commit 6336c53。
  • 经验:实例(子进程)重启频率高(插件升级/崩溃/回收都会),任何跨进程复用的连接池都要防半开;遇到"服务看似活着但请求挂起",先查 proxy 转发层。

回滚 / 注意

  • 备份:/opt/dsh/backups/server-login-src-pre-reap-20260909.tgz(src);DB 无需迁移(无 schema 变更)。
  • 升级流程:备份 → 改 src → npm run build → systemctl restart dshs → 验证。
  • 单会话上线后,同一账号的临时测试 sid 直插模式仍可用(不走 login 不受顶下线约束),但真实登录只会有一个活跃 sid; 多窗口/多设备联调时需注意(后登录会顶掉先登录的调试窗口)。
  • 默认 cap=4 基于当前 2GB 机器(约 760MB 实例预算);机器扩容后调大 DSHS_MAX_IDLE_INSTANCES 即可。