Files
dsh_ai1net_server/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

99 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 即可。