# 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 (原因; 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` 即可。