v0.1 深度测试报告
日期:2026-08-18。范围:在 v0.1 真机验收(docs/05)之后,针对系统各层做一轮深度测试,目标是尽量找出各方面问题。方法:4 个并行测试 agent(API 边界 / 调度器不变量 / runner 故障模式 / 数据层语义),各自独立起本地 server+agent、独立端口(18701–18704),外加人工 WebUI 走查与 5090 真机复验。
1. 测试矩阵
Section titled “1. 测试矩阵”| 方向 | 覆盖内容 | 主要发现 |
|---|---|---|
| REST 边界 | 畸形 ID/JSON、auth 矩阵、分页上下界、cancel/retry 语义、SDK 批量去重、超大载荷、logs 端点 | int64 溢出 500、负 limit、幽灵 checkpoint id、seq 烧掉 |
| 调度器不变量 | 优先级序、FIFO、cpu 车道上限、GPU-less 节点排队、重试矩阵、node_selector、dispatch 超时、churn | 13 探针:10 OK、1 BUG(队头阻塞,缺陷 11)、2 WEIRD 已接受(§5);优先级序真机复验 ✓ |
| runner 故障模式 | 命令不存在、cwd 缺失、退出码、OOM 正则、cancel 竞态、大日志 ring、unicode、env 传递、agent 重启中途 | 12 探针:10 OK、1 WEIRD(env/token 泄漏,缺陷 19/20)、1 BUG(优雅关停误标,缺陷 18);另见 SDK PYTHONPATH(缺陷 10) |
| 数据层 | cursor 去重/空洞、summary 数学、checkpoint attach 先后、SDK 本地 spool、server 重启中途、import 幂等、并发写、事件过滤 | 37 探针:26 OK、6 BUG(缺陷 5/9/13/12/15/16)、5 WEIRD(3 修复/接受,8b 实为缺陷 17);SDK flush 竞态(缺陷 9,真机 19/20 → 20/20) |
| WebUI | 首页三卡、RunDetail 四 tab、JobDetail 日志、NaN 显示 | latest=null 时 Overview 崩溃(toPrecision on null) |
2. 已确认并修复的缺陷
Section titled “2. 已确认并修复的缺陷”| # | 缺陷 | 现象 | 修复 |
|---|---|---|---|
| 1 | int64 溢出 → 500(6 端点) | priority=10**30、before=10**30、metrics ts/seq/step、checkpoint step 越界均 500 |
请求模型/Query 加 ge/le=2**62 边界 → 422(models.py MAX_I64_SAFE) |
| 2 | 负 limit/tail 被接受 | SQLite LIMIT -1 = 无界,击穿行数上限 |
所有分页/日志 Query 加 ge=1(metrics limit 加 ge=1, include_tail ge=0) |
| 3 | checkpoint 重放返回幽灵 id | 同 (run,path) 二次 POST 返回新 id(DB 实际 upsert 保旧 id)→ best.checkpoint_id 指向不存在 id | upsert_checkpoint 回读存储行返回(store.py) |
| 4 | seq 先消费后插入 → 静默丢批 | 插入失败后同 seq 重试被 dedup,数据永久丢失 | cursor+insert 合并为单事务(insert_metrics_once / insert_node_metrics_once / append_logs_once),失败不烧 seq |
| 5 | metrics LIMIT 取最早 N 点 | 长 run 图表/趋势只看开头;trend 冻结在 run 前 20 点,永不反映近期走势 | ORDER BY ts DESC LIMIT n 后反转(store.py query_metrics / query_node_metrics) |
| 6 | RunDetail latest=null 崩溃 | NaN/Inf 处理后 latest 可为 null,Overview v.toPrecision(5) 抛 TypeError,整页白屏 |
null 渲染为红色 non-finite(RunDetail.tsx + api.ts 类型) |
| 7 | 错误信封不一致 | pydantic 422 / 未知路由 404 / 错动词 405 返回 FastAPI 裸 {"detail":...} |
统一 {error:{code,message,details}} 处理器(app.py) |
| 8 | 静默接受非法值 | gpu=-1、max_attempts=0/-3、name=''、source=bogus、checkpoint step=-5/size=-1 照收 |
请求模型加约束(name min_length、gpu ge=0、max_attempts ge=1、source pattern、step/size ge=0) |
| 9 | SDK flush 竞态丢最后一批 | 20 个指标点只落 19 个(真机复现):finish() 只等队列空,daemon 发送线程在进程退出时被杀死,pending 批量丢失 |
按需 flush:sentinel 唤醒 + _pending_n/_in_flight 追踪,flush 等到真正交付完(_run.py);真机复验 20/20 |
| 10 | 作业内 import gpuctl 失败 |
runner 注入 GPUCTL_* 环境但未注入 SDK 路径,conda python 作业 ModuleNotFoundError | runner 自动注入版本匹配的 SDK src 到 PYTHONPATH(显式 job PYTHONPATH 优先);真机复验 SDK attach 成功 |
| 11 | 调度器队头阻塞(test-sched #4b) | GPU 任务在队首且无空闲槽位时调度器直接返回,其后的可调度任务(如 cpu_only)永远不被派发 | _dispatch_next 遍历全部 QUEUED、跳过不可调度项(单 tick 仍只派发一个);回归测试:GPU 占用 + cpu 任务 → 后者被派发 ✓ |
| 12 | spool 重放在新流量之后(test-data #6) | 恢复联机后先 POST 新批次推高 ingest cursor,spool 里的旧 seq 被 server 当重放 dedup → 断线期间的点永久丢失(docstring 声称先重放,代码实际相反) | _deliver_metrics/_deliver 先 _drain_spool() 再发新批次(_run.py);回归测试验证 POST 顺序 [旧 seq, 新 seq] 与失败保序 ✓ |
| 13 | import httpx 在 try 块外(test-data #5c) |
环境缺 httpx 时 _register 初始化即抛异常,本地 spool 兜底失效(观测 errors=12 / state=new / run_id=None) |
import 移入 try + fallback 日志(_run.py);回归测试 monkeypatch sys.modules['httpx']=None → 本地模式 ✓ |
| 14 | flush sentinel 无 kind 键杀死发送线程 | {"_flush": True} 不含 “kind”,sender 分支 item.get("kind")=="_flush" 永不命中 → 每次 flush 交付后线程 KeyError 崩溃;首次 flush 因崩溃前已交付而掩盖问题,同进程后续 flush 挂起丢批(回归测试暴露) |
分支改判 item.get("_flush")(_run.py);回归测试单进程双 flush;真机 3×flush 全 queued=0 ✓ |
| 15 | step 过滤在 LIMIT 之后(test-data #11c) | step_to 窗口 + limit 组合下先截断后过滤,窄窗口整体被过滤空 |
step 条件并入 SQL WHERE(先于 LIMIT)(store.py query_metrics);回归测试 step_to=1&limit=3 → [0,1] ✓ |
| 16 | ensure_project/experiment 并发竞态(test-data #12b) | SELECT-then-INSERT 窗口期第二个注册抛 IntegrityError → 500 | INSERT … ON CONFLICT DO NOTHING 优先再 SELECT(store.py);回归测试 asyncio.gather 并发 ✓ |
| 17 | backup 同秒覆盖(test-data #8b) | 文件名精度仅到秒;同一秒两次备份 → 第二次静默覆盖第一次 | datetime.now().strftime('%Y%m%d-%H%M%S-%f')(time.strftime 不支持 %f,会输出字面量 f —— 首轮修复即栽在此处);真机同秒两次 → 两个文件 ✓ |
| 18 | agent 优雅关停误标 FAILED/LOST(test-agent #P11) | 设计:agent SIGTERM → 取消任务进程组 → 报告 CANCELLED。实际:disconnect 即标 LOST;重连后 reconcile 先于 spool 回放,local_state.exited 恒为空 → _on_reconcile 终态化 FAILED/LOST,spool 里的真实 CANCELLED(-15) 被 is_terminal 守卫丢弃 → 假 JOB_LOST CRITICAL、真 exit_code 丢失、无 RUN_FINISHED |
双向修复:agent 重连先回放 spool 再 reconcile(main.py,与 runner 注释意图一致);server 对优雅关闭(ws close code 1000)跳过 LOST 标记(gateway.py),只有异常断开立即标 LOST。真机复验:RUNNING 中杀 agent 重启 → CANCELLED exit=-15、事件流无 JOB_LOST ✓ |
| 19 | write scope token 注入作业(test-agent #P9a) | 无 sdk-scope token 时调度器回退注入 write(admin)token —— 作业代码获得提交/取消/备份的完整 API 权限 | token 注入仅限 sdk scope;缺失时警告并降级为 spool 摄取(scheduler.py);真机复验 job env GPUCTL_TOKEN=wsl5090-sdk-token ✓ |
| 20 | agent 环境泄漏进作业(test-agent #P9b) | env=dict(os.environ) 把 agent 的 GPUCTL_AGENT_CONFIG(含 ws token)等整份环境拷给作业 |
继承环境剥离 GPUCTL_* 前缀后再注入受控变量(runner.py);回归测试断言 AC= |
2b. 判定为设计内行为(验证通过)
Section titled “2b. 判定为设计内行为(验证通过)”- 优先级排序(真机):排队中的任务按
priority DESC先来先启动——GPU 释放后 100→50→-50 依次启动 ✓ - 非抢占(设计 docs/03 §: 不抢占):已在跑的低优先级任务不会被新提交的高优先级任务打断(先提交先运行)✓
- NaN/Inf 全链路:SDK→server→SQLite→API→UI(
non-finite红色标记 + 图表断线 + LOSS_NAN 事件)✓
3. 真机复验(WSL 5090,通过 18601 转发)
Section titled “3. 真机复验(WSL 5090,通过 18601 转发)”limit=-1→ 422 validation_error 信封;空 name → 422;未知路由 → 404 信封 ✓- NaN probe run:
latest为 null、series 为 null 空洞、LOSS_NAN critical 事件、best 保持 0.4@step2(跳过非有限值)✓(截图 nan_overview/nan_metrics/nan_events.png) - 服务重启后节点 3s 内回 ONLINE ✓
- 多 flush 单进程探针(缺陷 14 复验):同一进程内 3 次
flush(),每次返回时 queued=0,最终 3/3 指标点落库 ✓(旧代码首次 flush 即杀死发送线程) - 同秒两次 backup(缺陷 17 复验):
…174356-447483.db与…174356-525055.db两个文件并存 ✓ - agent 优雅重启(缺陷 18 复验):RUNNING 中
pkill -f gpuctl-agent后重启 → 任务 CANCELLED、exit_code=-15、事件流仅 RUN_FINISHED/JOB_DISPATCHED/JOB_QUEUED(无 JOB_LOST / AGENT_DISCONNECTED)✓ - 作业环境(缺陷 19/20 复验):job env 为
GPUCTL_TOKEN=wsl5090-sdk-token(sdk scope,非 admin write),无GPUCTL_AGENT_CONFIG✓
4. WebUI 变更(本轮)
Section titled “4. WebUI 变更(本轮)”- 首页 NodeCard:CPU 利用率数字 + sparkline(修复此前仅图形无数值的问题);新增 MEM / VRAM 横向 Meter(绿<60% / 琥珀<85% / 红≥85%,显示 used/total)
- RunDetail tab 进入 URL(
?tab=Metrics),可分享/书签(顺带使无头验证可行) - RunDetail Overview 对非有限 latest 显示红色
non-finite(事件流里有精确 NaN/Inf 详情) settoken.html纳入web/public/(构建自动拷贝;新格式?tk=TOKEN&to=/route,不再硬编码 token)
5. 边界行为(v0.1 判定可接受,文档化)
Section titled “5. 边界行为(v0.1 判定可接受,文档化)”GET /jobs/(尾斜杠)→ 404 信封(static mount 吞掉 307;返回 JSON 错误而非 HTML,可接受)GET /jobs/nonexistent/runs→ 200 [](集合空语义)logs?stream=bogus→ 200 空行 + note(容忍未知 stream)GET /jobs/{id}/cancel用错动词 → 404(FastAPI 路径注册语义)- 乱序 seq(test-data #1d):server 按 (run_id, seq) 单调去重;空洞后补发的旧 seq 被当重放 dedup(文档化行为;SDK 单线程顺序发送,实际不会产生该形态)
- 重复 import 生成重复 run(test-data #7):import 是显式动作,重复执行不幂等(按设计)
- ws-ping 超时先于 dispatch-ack(test-sched #7b):agent 被 SIGSTOP 时 ws ping 超时 → 节点判离线 → 运行中任务标记 LOST,而非 dispatch-ack 超时 requeue;SIGSTOP-resume 后 agent 重连恢复正确(LOST 非终止、可重连,接受)
6. 回归测试
Section titled “6. 回归测试”新增 21 个用例(31 → 52 总数):validation bounds、envelope 一致性、checkpoint 重放同 id、cursor 失败不烧 seq、limit 取最新 + trend 跟随近期窗口、step 过滤先于 LIMIT(缺陷 15)、ensure_project 并发竞态(缺陷 16)、backup 文件名唯一(缺陷 17)、HOL 队头跳过(缺陷 11,scheduler 单测)、graceful/abrupt disconnect 的 LOST 差异(缺陷 18)、sdk-only token 注入(缺陷 19)、SDK flush 等在途交付(3 个)、spool 先于新流量(缺陷 12)、spool 失败保序、flush sentinel 不杀线程(缺陷 14)、httpx 缺失本地兜底(缺陷 13)、runner SDK PYTHONPATH 注入、runner 环境不泄漏(缺陷 20)。README 计数已同步为 52。
7. 测试 agent 结果(真实判定)
Section titled “7. 测试 agent 结果(真实判定)”四个 agent 各自独立起本地 server+agent(端口 18701–18704)完成全部场景;test-sched / test-data / test-agent 经 teammate 通道交付了完整报告,结论可信:
| agent | 场景 | 判定 |
|---|---|---|
| test-api | REST 边界(44 次提交) | 交付 final_findings.json;其 BUG 均已在缺陷 1–10 轮修复并回归 |
| test-sched | 调度器不变量 13 探针 | 10 OK(优先级序 / FIFO / cpu 车道 / 重试 / 取消 / churn / ack 超时精确 30s);1 BUG = 缺陷 11(HOL 队头阻塞,已修复);2 WEIRD 已接受(§5:7b ws-ping 优先、10 事件缺口 → 已补 JOB_CANCELLED/JOB_REQUEUED/JOB_DISPATCH_FAILED) |
| test-data | 数据层 37 探针 | 26 OK;6 BUG = 缺陷 5(2f LIMIT 序)、9(5a flush)、13(5c httpx)、12(6 spool 序)、15(11c step 过滤)、16(12b 并发注册),全部已修复并回归;5 WEIRD:9d/extra-a 已修复,1d/7 接受(§5),8b 实为缺陷 17 |
| test-agent | runner 故障模式 12 探针 | 10 OK(启动失败→DISPATCH_FAILED 快速失败、exit0/3→SUCCEEDED/EXIT_CODE、segv→SIGNAL(11)、OOM 检测、取消幂等、大日志 tail=2000、unicode 逐字节往返、runtime.json、并发 cpu 车道 2+2 全过);1 WEIRD = 缺陷 19/20(token 与 env 泄漏,已修复);1 BUG = 缺陷 18(优雅关停误标 FAILED/LOST,已修复并真机复验) |