Files
HomeAgent/scripts/kernel-stress
JianFeeeee 79c15f0452 test(webui): 压测补齐另外 3 个插件的路由 —— 我第一版漏了两个
第一版只压了 webui 的 `/api/v1/*`。核实后发现 **8080 上注册 HTTP 路由的
插件有 4 个,监听端口还不止 8080**(`ss -ltnp` 实测):

| 端口 | 插件 | 路由 | 认证 |
| --- | --- | --- | --- |
| 127.0.0.1:8080 | webui | /api/v1/* | config_webui.api_key |
| | remotedevice | /api/v1/device/* | config_remotedevice.ws_token |
| | kbtree 子路径 | /api/v1/knowledge/tree/{categories,counts} | config_webui.api_key |
| 127.0.0.1:9876 | **pluginmgr** | /plugins(**无** /api/v1) | **无需认证** |
| 127.0.0.1:9892 | **kbtree** | /categories /counts(**无**前缀) | config_kbtree.token |
| 127.0.0.1:9890 | remotedevice | 设备 WS 网关(非 REST) | 不压 |

⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立:`:8080/plugins`
实测 **404**。三套认证各不相同,脚本现在按分组取对应 token。

## 端点 18 → 24

新增:`/api/v1/device/online`(remotedevice)、`/plugins`(pluginmgr)、
`/categories` `/counts`(kbtree 根路由)、以及 webui 前缀内的
`/api/v1/knowledge/tree/{categories,counts}`。

收 `/api/v1/device/online` 之前逐行读过实现:仅 `GET` +
`registry.OnlineList()`,纯读。**不收** `/device/push`(向真实设备下发)、
`/device/ws`(长连接)、`/device/{id}`(语义未核实)。

## 实测(24 端点 × 3 档,2880 请求零失败)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | 480/480 |
| 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | 960/960 |
| 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | 1440/1440 |

压测期间服务端 `active`、0 个 5xx。

**p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。

## 又踩了一次前缀的坑(两种方向都踩了)

1. 「表里存路径后半段 + 代码按 group 补前缀」⇒ remotedevice 拼成
   `/api/v1/api/v1/device/online` ⇒ 落到 webui 兜底路由、
   返回 **200 + 登录页 HTML**(靠 `looks_like_login_page()` 抓到)。
2. 反过来「表里已含前缀 + 代码仍补」⇒ webui 组全 404。

⇒ 最终统一成**表里写完整路径、代码不补**。两次都是靠 `--probe` 抓到的,
这正是它存在的理由。

## 顺带修掉我写错的一处

`contextlib.suppress(sqlite3.connect)` —— `connect` 是函数不是异常类,
`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`,
并把 `config.db` 的打开方式保持 `mode=ro`(压测不碰生产库写路径)。

## 文档里我自己写错又改正的一处

§7 表格里 scale=1 一行先写成 1680/2013/5.4ms,核对 JSON 后实为
**480/953/4.0ms**(24 端点 × 20 轮)。已订正,并加了提交前的
JSON 逐项比对,三档现已全部一致。
2026-09-28 09:33:22 +08:00
..

内核二进制压力测试(kernel-stress)

对本仓库编译出来的真实内核做压力测试 —— 与 go test 的区别是:它跑真二进制、 真插件加载、真 unix socket 协议,因此能抓到只在集成面上出现的问题 (已有战绩:根 agent DataDir 漏接线、插件通道没登记为 inputch、create 后子不开工)。

为什么必须放在私有 netns 里

生产实例占着 *:8080 / *:9890 / *:9876,而插件的监听都设了 SO_REUSEADDR: 同机再起一个实例会在 127.0.0.1 上与之并存绑定(实测抢到过 127.0.0.1:9890 约 1 分钟)。 unshare -n 后实例只有 lo,结构上不可能碰到生产端口。 unix socket 是文件系统对象,跨 netns 仍可驱动,所以驱动脚本在 netns 外也能用。

前置

go build -o /tmp/homed-stress ./cmd/homed      # 被压的内核
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp

用法

# 1) 准备数据目录 + 把 LLM 指向本地 mock(无外网也能跑,且快、可控)
DATA=/var/tmp/kstress
mkdir -p $DATA
#  先跑一次实例建出 config.db,再写入下面这些键(也可直接复用现成目录):
#    core.llm.provider=mock
#    core.llm.sources.mock.base_url=http://127.0.0.1:9099/v1
#    core.llm.sources.mock.model=mock   api_key=mock   adapter=openai
#    core.llm.sources.mock.adapter_path=adapters/openai.lua   priority=100
#    core.defaults.llm_endpoints=http://127.0.0.1:9099/v1/models   # 探活端点(探活用 HEAD!)
#    core.defaults.rollback.max_retries=100000   auto_rollback=false
#  并把 core.llm.sources.deepseek* 删掉(netns 里它不可达,会让 agent 被判 degraded → rollback 循环)

# 2) 起 mock LLM + 内核(都在同一个私有 netns 里)
MOCK_DELAY_MS=300 MOCK_CHUNKS=8 ./launch.sh /tmp/homed-stress

# 3) 取认证密钥(cli 插件回落到 webui.api_key)
export KCLI_KEY=$(sqlite3 $DATA/config.db "select value from config_webui where key='api_key';")

# 4) 压
./kcli.py $DATA/cli.sock stats full            # 看内核状态(含 scheduler 计数)
./stress.py $DATA/cli.sock "$KCLI_KEY" 16 12 4 20 dense 0.05   # 16 连接×12 输入 + 4 线程×20 中断
./stress.py $DATA/cli.sock "$KCLI_KEY" 12 12 1 25 0.0 3.0      # 稀疏中断 ⇒ 压抢占/挂起/恢复
./stress.py $DATA/cli.sock "$KCLI_KEY" 1 1 0 0 resident 0.0    # 驻留子全链路(mock 见 !resident 标记)

远程设备(agent ↔ 设备)端到端

设备网关在实例的 netns 内监听 127.0.0.1:9890,所以设备客户端要进同一个 netns 跑:

TOKEN=$(sqlite3 $DATA/config.db "select value from config_remotedevice where key='ws_token';")
nsenter -t $(cat $DATA/pid) -n python3 ./devclient.py --port 9890 --token "$TOKEN" \
    --id pydev-1 --caps cmd --seconds 30 --out /var/tmp/push.txt

然后让 agent 主动发一条(mock 里 !push 会回一个 output_send__device/pydev-1 的工具调用):

# 经 CLI socket 发 "!push",设备侧应收到 {"op":"push", ...}

设备上线/下线会在内核里登记/注销输出通道 device/<id>,可用 /kernel 的 channels 观察 (在线时出现、掉线后消失)。

设计口径:agent → 设备必须是 agent 的主动调用(output_send__device/<id>); 设备的上报(op=event)虽然会被注入成输入,但 agent 的回复不会被插件自动转回设备 —— 全仓只有 webui 与 cli 两个交互界面"主动转发"(把最终回复渲染成气泡/终端输出), 其它通道(qq、设备等)一律要求显式 output_send__<通道>。

读结果

指标 含义
executed / rejected 任务执行数 / 被拒数(压力下应为 0)
峰值 峰值_队列 / 峰值_待处理中断 采样到的最大排队深度 / 待处理中断数
suspended / resumed / preempted 抢占三件套。中断要放稀才会打在高优先级任务上:密集中断会互相同级(L4 vs L4)不抢占,数值会很低
max_suspend_depth 中断栈结构上界(= 中断级数 4)

已知坑

  • 探活用 HEAD(internal/network/monitor.go):mock 必须实现 do_HEAD,否则 501 → 判不可达 → agent degraded → rollback 循环(会 reset 合并目录)。
  • CLI 协议每条连接串行处理(handleChat 阻塞到本次响应产出):单连接狂发只会排成一条线,造不出队列压力 —— 必须多连接。
  • 认证:连接后先发 /auth <key>,否则一切命令返回 unauthorized。
  • mockllm.py 的 !resident / !notify 标记用来让 mock 回工具调用,从而在真内核里驱动 resident_agents / notify_parent。