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 一律写「远程服务器」。
6.4 KiB
6.4 KiB
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)
- 确认实现:最大常驻数量 + 自动回收(默认
cap=4、空闲TTL=7d、扫描60s,均可 env 覆盖)。 - 同账号多设备 = 单活跃会话(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 按顺序执行):
- 空闲 TTL 回收:
lastActive距今 > TTL 的 main →stop(); - 数量上限(LRU):剩余常驻数 > cap → 按
lastActive升序停掉差额; - 每次回收在 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即可。