Merge pull request 'Drop remaining hardcoded test ports in favor of free_test_port()' (#676) from fix/remaining-hardcoded-ports into main
ci / svc-realcombo (push) Successful in 3m23s
ci / test (push) Successful in 8m56s
ci / test-pg (push) Successful in 10m33s

This commit was merged in pull request #676.
This commit is contained in:
2026-10-21 11:04:28 +00:00
6 changed files with 246 additions and 14 deletions
@@ -0,0 +1,96 @@
# fix/remaining-hardcoded-ports 任务卡
任务主记录:PR #676 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/676;状态:已完成(合入前闭合);owner:weichen
- 权威仓:ssh://git@192.168.3.2:2222/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 444fb1dee2a72452be23b97cbaaad9e28c42dffd
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-fix-remaining-hardcoded-ports(worktree);分支:fix/remaining-hardcoded-ports
- 分支类型:standalone;root_target:main;immediate_target:main;accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=f58f9b9127d88193b75ab8240478a0be0643a63075d58cc54884dfc7b9b4e64e;triage_receipt=/tmp/triage-remaining-port-flakes-20261021.json;receipt_sha256=e190b63d6a5bbcb0285d38a8ae9e1a548a6fb3e3a16dffe8e96dd54cc344a27b
- 授权与交付终点:消除 PR #668 Review 记下的同类端口硬编码脆弱面,提取 helper 并应用到 tests/test_svc.py 与 tests/test_svc_realcombo.py 的相关位点。不得改 scripts/svc.sh、不得削弱断言、不得改动 test_svc_recover.py / test_task_pr_check.py / test_task_triage.py 的端口。
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | 444fb1dee2a72452be23b97cbaaad9e28c42dffd | 444fb1dee2a72452be23b97cbaaad9e28c42dffd | 2026-10-21T10:19:04Z |
## 证据(Evidence)
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|---|---|---|---|---|---|
## 结果与验证
- 结果:新增 `tests/_port_alloc.py`(`free_test_port()` 每次调用通过 socket reservation 拿一个真正空闲的端口,with 块退出立即 close)。修改 `tests/test_svc.py`(6 处 `svc ... --port 18842/18843/18844` 改为每次 alloc 一个端口,668 与 942 行使用同一分配值因为是同一测试中相同实例的重复确认)和 `tests/test_svc_realcombo.py`(3 个用例各 alloc 一次端口,seed_real/svc/http_ok 三处共享)。`register_pg`/`DSN 18951` 按 PR #668 Review 注明仅写 svc.env、从不绑端口,保留不动;`test_svc_recover.py` 已有自己的 `free_fixture_port` 不动;`test_task_pr_check.py`/`test_task_triage.py` 的端口仅作字典键,不绑端口。
- 验证:PASS——`tests.test_port_alloc` 在 64 负载下 OK;`tests.test_svc` 23 项 OK;治理套件 527 项 OK(18 skipped);PR #668 的目标用例 `test_static_fresh_deploy_registers_pidcheck_and_starts` 仍通过。独立 Review(pi-glm,PASS 无阻塞项)。
- 影响与剩余项:不改任何生产代码;不削弱断言;realcombo 三例的运行级验证 NOT_RUN(环境门禁与禁网限制),由代码走读与模块级 skip 确认替代。Review 记 3 项非阻塞建议(守护测试未覆盖「外部占用」与 TIME_WAIT 避让、文件名单硬编码而非 glob、helper API 单端口 vs 真实用例可能需多端口对),均不阻塞。
## 合入与清理
- Review 记录:PASS(独立会话 pi-glm / zai-coding-cn/glm-5.3-flash;base 444fb1dee2a72452be23b97cbaaad9e28c42dffd,head 076a686592f99abb7b4e8c1aa73b7c73b5bccefe;报告 `.task/reviews/2026-10-20-remaining-ports-review.md`;无阻塞项,3 项非阻塞建议;重放依据:host 独立跑治理套件与 64 负载下的 helper 测试)
- 冲突记录:NOT_RUN(一次交付前同步,无变更)
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
```json
{
"summary_schema": 1,
"source": "SELF",
"timestamp": "2026-10-21T10:18:17.467858+00:00",
"command": "make precheck",
"exit_code": 0,
"cwd": "/home/weichen/projects/Agent-System-Dev",
"before_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"after_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"branch": "main",
"target_branch": "main",
"target_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"root_target": "main",
"immediate_target": "main",
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"output_tail": [
"[repo] branch=main head=444fb1dee2",
"[remote] origin/main=444fb1dee2 ahead=0 behind=0",
"[tree] clean submodules_drift=0 uninit=0",
"[worktrees] linked=2",
"[recycle] merged un-recycled branches: feature/card-close-20261021-085838Z",
"[initiatives] local cards (not authoritative): none",
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
"[facts] policy/facts.md 6 条事实均在有效期内",
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
"[R0] 444fb1dee2(完整:444fb1dee2a72452be23b97cbaaad9e28c42dffd)",
"[worktree] before-sync: recursive component checks passed",
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
],
"output_sha256": "3ab6ad8a3228f50b19b9184ab2ded12a122fb8ecf601866a50c269dd41cf03e2",
"receipt_sha256": "8bbfc6e4ccd048cce9e097878522ee44a50c32215c0c1df2ab59c8f7d7ae9a83",
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/8bbfc6e4ccd048cce9e097878522ee44a50c32215c0c1df2ab59c8f7d7ae9a83.json"
}
```
## 闭合记录(合入前折叠)
- 本卡在 PR #676(https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/676)合入前闭合并归档,随该 PR 一同合入。
- 该 PR 的合并提交即本任务的落地记录。
- 合入后无需回填,也无需单独的闭合 PR。
@@ -0,0 +1,86 @@
# 独立代码审查:fix/remaining-hardcoded-ports(剩余同类端口硬编码改为内核分配,与 PR #668 同形)
- 审查对象:`444fb1dee2..HEAD`(HEAD=70c810a)。本单元 diff = `.task/fix-remaining-hardcoded-ports.md`(host 豁免路径)+ `tests/_port_alloc.py`(新增 13 行)+ `tests/test_port_alloc.py`(新增 24 行)+ `tests/test_svc.py`(4 处用例、6 个 `svc --port` 调用点 + 批量更新 2 个 seed 端口)+ `tests/test_svc_realcombo.py`(3 个 realcombo 用例 + import)。产品侧 `scripts/svc.sh` 零改动(`git diff 444fb1dee2..HEAD --stat -- scripts/` 为空)。
- 审查者身份:全新会话独立审查,未参与实现;本会话除本报告外不写任何仓库文件;无 git add/commit/push,无 PR 操作。工作区(detached HEAD 70c810a)在报告写入前后 `git status --porcelain` 仅有本报告。
- 依据:PR #668 审查(.task/reviews/2026-10-20-svc-flake-review.md)N4 记录的同类脆弱面 =「rdm 组真实绑口的 18842/18843/18844 与 realcombo 9471/9473/9474」;本单元即对该类做同形治理(bind(("127.0.0.1", 0)) 内核分配)。
## 结论:PASS(无阻塞项;非阻塞建议 3 条)
判定标准逐项:契约用例不再依赖固定端口(实测全数转换,动态临时口 32768-60999 与全仓剩余固定口不相交);helper 与 PR #668 及仓内既有 5 处夹具(test_svc_recover.py:187 `free_fixture_port` 等)逐字节同构,非新模式;受保护内容(`scripts/svc.sh`、`register_pg`/DSN 18951、`test_svc_recover.py`、`test_task_pr_check.py`、`test_task_triage.py`)零改动;补丁未外溢(`git diff --name-only` 仅 5 个文件)。realcombo 三例因需真实二进制环境(SVC_TEST_REAL / SVC_REAL_RDM_BIN / SVC_RDM_ORIGIN,本单元禁网)为 NOT_RUN,以代码走读 + 本地只读模块跑通(16 skipped 门槛确认)替代。
---
## 重点 1:helper `free_test_port` 的设计
**写法**(tests/_port_alloc.py):每次调用新开 `AF_INET/SOCK_STREAM` 套接字 → `bind(("127.0.0.1", 0))` 由内核从临时口范围分配 → `getsockname()[1]` 取实际端口 → `with` 退出即 close(return 表达式在 with 内求值,关闭发生在返回前)。不池化、不 SO_REUSEPORT,与 `free_fixture_port`(test_svc_recover.py:187-190)及 PR #668 写法同构。docstring 如实声明「closes it before returning」「Ports are not pooled」。
**「真空闲」的三类场景实测**(合成测试 /tmp/port_alloc_synthetic.py,独立于守护测试,3 次运行全 7/7 PASS):
1. **外部占用**:先取 helper 返回端口 P1 并真 LISTEN 占住(SO_ACCEPTCONN=1 确认),再次调用 helper 与后续 50 次调用共 51 次,**0 次命中 P1**,且每个返回值都能立即 bind+listen 成功——内核 `bind(0)` 的冲突检测以套接字表为准,与占用者是本进程还是外部进程无关,故 in-process LISTEN 持有者可作外部占用的等价证据。被占端口本身确实可用(我们正是用它完成 LISTEN)。
2. **TIME_WAIT**:制造真实 TIME_WAIT(服务端先关,端口 45387/53933/42733 三轮),先证明无 SO_REUSEADDR 的裸 rebind 报 EADDRINUSE(TIME_WAIT 确实阻断重绑),再测 helper 50 次调用 **0 次命中** TIME_WAIT 端口——内核分配阶段即跳过,helper 不会把调用方推进一个必然 bind 失败的端口。
3. **多调用区分度**:连续 500 次无持有调用 → 497 个不同值(远处可重复 3 次),但**相邻两次重复 0/499**——内核临时口游标使相邻调用实际上总不同。守护测试的 `assertNotEqual(first, second)` 依赖的正是相邻性,实证可靠(另 3×50 连续调用亦全不同)。远处重复(如两次 alloc 间隔数百次调用后撞回同值)无害:前一次的 reservation 早已 close 且从未 LISTEN 过、无 TIME_WAIT、无持有者,调用方 bind 必成功。
4. **子进程仍持套接字**:与场景 1 同表(内核冲突表不分进程),daemon 未退时 helper 不会返回其端口;helper 自身的 reservation 从未 accept/连接,close 后不留 TIME_WAIT。
**残余 TOCTOU 窗口(非阻塞,与 PR #668 同等接受)**:reservation close 到 daemon 真正 bind 之间端口不被保留。这是该模式(含仓内既有 5 处)的固有窗口,docstring 已如实声明。失效模式是响亮失败而非假绿:端口真被抢时 `check_port_free`(svc.sh:1575)报「端口被占用(非本服务)」非零退出 → `self.svc(check=True)` 断言带全量 stdout 失败,可归因(`port_owner`)。动态口 32768-60999(实测 `/proc/sys/net/ipv4/ip_local_port_range`)与仓内全部固定口(9431-9472、188xx-189xx)不相交,套件内不会互相踩。套件级彻底解法(传 fd/重试)属独立增量,不建议本任务扩围。
## 重点 2:三个 realcombo 用例端口一致性
逐例核验(tests/test_svc_realcombo.py):
- `test_U3_real_agent_identity_lifecycle_sqlite`:`:172 port = free_test_port()` 一次 → `seed_real("rc1", port)`(写 svc.env 的 addr 与 exec `--addr 127.0.0.1:port`)→ `svc start` → `http_ok(port)`(:186)→ 两次裸 restart(svc restart 沿用登记端口)→ `http_ok(port)`(:211)→ stop。三处同源同值。✓
- `test_U3_real_rdm_binary_lifecycle_sqlite`:`:219` 一次 alloc → seed_real/start/http_ok(:226)全用 `port`。✓
- `test_U3_real_rdm_full_deploy_track`:`:245 agent_port = free_test_port()` 一次 → `deploy --port str(agent_port)` → 裸 restart 后 `http_ok(agent_port)`(:269)。伴生 `rcfweb` 的端口不归本 alloc:svc.sh `deploy_rdm_companion_ui`(svc.sh:3141)以无 --port 子进程走 `derive_port`(8800+hash%300 冲突递增),测试从 `kv("rcfweb","addr")` 读回再探活(:263)——读写分离正确,alloc 只管 agent 口。✓
文件内 9471/9473/9474 零残留(grep 实测)。**alloc→bind 窗口在 realcombo 最宽**(seed_real 里的 `initialize_store` 会起停一个独立临时口的 fixture 服务,秒级),但该窗口语义与重点 1 相同:碰撞即响亮失败(start 门禁报占用),不会假绿;环境门禁(无真实二进制即 skip)意味着 CI 常规路径根本不执行,风险面进一步收窄。NOT_RUN 如实标注。
## 重点 3:test_svc.py 六个 `svc --port` 调用点
六个调用点(行号为 HEAD)全部经 `free_test_port()` 取值:`:904`(rdm e2e deploy)、`:931`(rdm 缺 web 失败 deploy)、`:944/:948/:954`(source-switch 三连)、`:970`(rdm update 轨道 deploy)。零 18841-18844 残留(grep 实测)。
**「同一测试多次 svc 调用端口应不同」的精确判定**:唯一的同测试多次调用是 `test_source_switch_requires_flag`,它 **有意** 共享一次 alloc(`:942 port = str(free_test_port())`,:944/:948/:954 三处同值)。这不违反意图:三个调用是对**同一实例 sw1** 的顺序 deploy → 被拒 deploy → 换源 redeploy;svc.sh deploy 流程「停旧 → 原子切换 → 启动」(agent_do_deploy,svc.sh:3211 起)在重绑前先停掉旧服务,`check_port_free`/`reclaim_own_port`(svc.sh:1575/1115)兜底本服务残留——端口不会「被自身 reuse 占满」,因为旧持有者在重绑前已被 svc.sh 自己停掉;被拒调用在 `require_allow_source_switch` 门禁就失败,从未 bind。该流程在改前用固定 18844 即已如此,本次仅换端口来源,实测通过(见证据 5)。每实例多端口场景(批量更新 a/b)则是每实例各 alloc 一次(`:673`),两次相邻调用必不同(重点 1.3)。判定:**无「每调用必须独立 alloc」的违例;共享 alloc 是对该用例语义的最小忠实转换。**
另有 `test_update_preserves_binding` 等用例经 update/restart 沿登记端口重启——属 svc.sh 持久化语义,端口来源已由 seed 处 alloc/保留,不在六调用点之列。
## 重点 4:守护测试 test_port_alloc 的覆盖度
**能否真的失败——能,两类回归可拦**:
- 退化成固定/缓存端口(如误改为 `return 18844`):`assertNotEqual(first, second)` 即刻失败——合成证据 4 用假 helper 复现了该失败路径。
- 返回不可绑端口(如未来改成返回他者持有口):循环内 `bind+listen` 直接抛 OSError → 用例 ERROR。
- 相邻两次拿到同值(游标回绕):assertNotEqual 失败(实测概率 ≈0,0/499 相邻重复,且该回绕对调用方无害——见重点 1.3,但作为回归信号仍成立)。
**覆盖缺口(非阻塞)**:守护测试未覆盖「端口已被外部占用时 helper 避开它」与 TIME_WAIT 避让——本审查以 /tmp 合成测试补证(外部 LISTEN 持有者 51 次调用 0 命中;TIME_WAIT 端口 50 次 0 命中),结论为 helper 行为正确,仅测试面未固化。alloc→bind TOCTOU 窗口则任何确定性测试都无法覆盖(内核不为已 close 的 reservation 保留端口),与 PR #668 的接受一致。若后续想固化,可在 `test_two_calls_...` 前置一步「先占住第一次的返回值再取第二次」的断言,属独立增量。
## 重点 5:范围核对
- `git diff --name-only 444fb1dee2..HEAD` 实测仅 5 文件:`.task/fix-remaining-hardcoded-ports.md` + 4 个测试文件;`scripts/` 零差异(`git diff --stat -- scripts/` 空)。✓
- `tests/test_svc_recover.py` 不动:正确——其已有同构 `free_fixture_port`(:187),全部用例早已内核分配。✓
- `tests/test_task_pr_check.py` 不动:正确——`ThreadingHTTPServer(("127.0.0.1", 0))`(:90)与 `closed_port_url()`(:100-102)本就内核分配,无固定绑口。✓
- `tests/test_task_triage.py` 不动:正确——夹具为内联 `bind(("127.0.0.1", 0))`(:230-232);文中的数字端口(如 30319)仅出现在 URL 脱敏字符串断言里,不 bind。✓
- `register_pg`/DSN 18951 保留:正确——`register_pg`(test_svc.py:242 起)仅 `write_text` 写 svc.env 元数据,从不建/绑 socket,18951 不可能引发占口型偶发失败。实测 base `444fb1dee2` 的 753-754 两行与 HEAD 逐字一致(行号平移到 757-758,系文件头新增 4 行 import 所致;本单元 diff 中 18951 出现次数为 0)。✓
**残余同类面(非阻塞,均不在本单元契约内,仅记录供后续增量)**:test_svc.py 仍有会在 `svc start/update` 时真实绑口的固定 seed 端口(18809、18832、18834-18837、18952,共 7 用例);`test_svc_plugins.py` 的 `deploy --port 18981/18982`、`test_svc_identity.py` 9431-9435、`test_svc_u2.py`/`test_svc_user_binding.py`/`test_svc_rdm_admin.py`/`test_svc_lifecycle_isolation.py` 的 943x-947x seed 端口同属 N4 类。动态临时口与它们不相交,本单元的转换不与之交互、也不掩盖其问题。
---
## 已运行的验证命令与结果(均只读;工作区除本报告外零修改)
| # | 命令 | 结果 |
|---|---|---|
| 1 | `git diff --stat 444fb1dee2..HEAD` 与 `--name-only` | 5 文件(任务卡 + 4 测试文件);`-- scripts/` 为空 |
| 2 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_port_alloc -v` | **OK**(Ran 1, 0.000s) |
| 3 | `python3 /tmp/port_alloc_synthetic.py`(合成测试:占住 helper 返回口再调用、51 次避让、TIME_WAIT 制造与避让、假 helper 触发断言;3 次运行) | 3 次 **exit=0,7/7 PASS**(典型值:held=36633 recall=49051;50-calls distinct=49-50/50 且 0 次命中被占口) |
| 4 | `python3 -c` 500 次连续调用统计 | 497/500 不同;**相邻重复 0/499** |
| 5 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest`(5 个改造用例:rdm e2e、rdm 缺 web、source-switch 三连、rdm update 轨道、批量更新双实例) | **OK**(Ran 5, 8.557s) |
| 6 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_realcombo -v` | OK(skipped=16:SVC_TEST_REAL/SVC_REAL_RDM_BIN/SVC_RDM_ORIGIN 未设 → realcombo 三例 NOT_RUN,禁网无法取得真实二进制) |
| 7 | `grep` 残留扫描(9471/9473/9474/18841-18844)与 `free_test_port` 调用点清单 | 两文件零残留;调用点 = test_svc.py:673/904/931/942/970 + realcombo:172/219/245 |
| 8 | `git show 444fb1dee2:tests/test_svc.py` 753-754 行 vs HEAD 757-758;本单元 diff 中 `18951` 计数 | 内容逐字一致;diff 计数 0 |
| 9 | `cat /proc/sys/net/ipv4/ip_local_port_range` | 32768 60999(与全部固定测试口不相交) |
| 10 | `git status --porcelain` / `git stash list`(测试运行前后) | 空(测试全在 /tmp 临时目录内自包含) |
## 判定标准对照
- 契约用例仍会因端口占用偶发失败:未发生(6 个 --port 调用点 + 批量更新 2 seed + realcombo 3 例全数转内核分配;动态口与仓内固定口不相交;占口失效模式为响亮失败)→ 非阻塞。
- helper 在外部占用/TIME_WAIT 下取错端口:未发生(合成实测 0 命中)→ 非阻塞。
- 受保护内容被改(svc.sh、register_pg 18951、recover/pr_check/triage 三文件):未发生(零 diff 实测)→ 非阻塞。
- 补丁外溢到契约外文件:未发生(5 文件实测,其一为 host 豁免的任务卡)→ 非阻塞。
- 结论:**PASS**。realcombo 三例运行级验证 NOT_RUN(环境门禁 + 禁网),以代码走读与模块级 skip 确认替代,已如实标注。
+13
View File
@@ -0,0 +1,13 @@
"""Shared free-port helper for svc tests (not a test module)."""
import socket
def free_test_port():
"""Return a port the kernel reports free on 127.0.0.1 right now.
Each call opens a fresh reservation and closes it before returning, so the
caller (e.g. `svc deploy`) can bind the port itself. Ports are not pooled.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
reservation.bind(("127.0.0.1", 0))
return reservation.getsockname()[1]
+24
View File
@@ -0,0 +1,24 @@
"""Regression for tests/_port_alloc.free_test_port."""
import os
import socket
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
class FreeTestPortTests(unittest.TestCase):
def test_two_calls_return_distinct_bindable_ports(self):
first = free_test_port()
second = free_test_port()
self.assertNotEqual(first, second)
for port in (first, second):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", port))
listener.listen(1)
self.assertEqual(listener.getsockname()[1], port)
if __name__ == "__main__":
unittest.main()
+13 -7
View File
@@ -10,12 +10,16 @@ import os
import shutil import shutil
import subprocess import subprocess
import socket import socket
import sys
import tempfile import tempfile
import unittest import unittest
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
SOURCE = Path(__file__).resolve().parents[1] SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh" SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
@@ -666,7 +670,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。 旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
""" """
old_sha, new_sha = self.make_src_repo() old_sha, new_sha = self.make_src_repo()
ports = {"a": 18841, "b": 18842} ports = {"a": free_test_port(), "b": free_test_port()}
old_pids = {} old_pids = {}
for name, port in ports.items(): for name, port in ports.items():
d = self.runtime(name) d = self.runtime(name)
@@ -897,7 +901,8 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_rdm_source_deploy_end_to_end(self): def test_rdm_source_deploy_end_to_end(self):
remote, work = self.make_rdm_repo() remote, work = self.make_rdm_repo()
env = self.rdm_env(remote) env = self.rdm_env(remote)
result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18843", env=env) result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", str(free_test_port()),
env=env)
d = self.runtime("rdm1") d = self.runtime("rdm1")
# U2:组合产物入不可变 release,经 current 指针解析;插件空集、工作台随 release。 # U2:组合产物入不可变 release,经 current 指针解析;插件空集、工作台随 release。
active = d / "current" active = d / "current"
@@ -923,7 +928,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_rdm_companion_missing_web_fails_deploy(self): def test_rdm_companion_missing_web_fails_deploy(self):
remote, work = self.make_rdm_repo(with_web=False) remote, work = self.make_rdm_repo(with_web=False)
env = self.rdm_env(remote) env = self.rdm_env(remote)
result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", "18843", result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", str(free_test_port()),
env=env, check=False) env=env, check=False)
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("ui-missing", result.stdout) self.assertIn("ui-missing", result.stdout)
@@ -934,18 +939,19 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_source_switch_requires_flag(self): def test_source_switch_requires_flag(self):
remote, work = self.make_rdm_repo() remote, work = self.make_rdm_repo()
env = self.rdm_env(remote) env = self.rdm_env(remote)
port = str(free_test_port())
# 先以 local 源部署(内嵌 UI 桩一律 200)。 # 先以 local 源部署(内嵌 UI 桩一律 200)。
self.svc("sw1", "deploy", "--from", "local", "--port", "18844") self.svc("sw1", "deploy", "--from", "local", "--port", port)
old = self.kv("sw1", "source") old = self.kv("sw1", "source")
self.assertTrue(old.startswith("local@")) self.assertTrue(old.startswith("local@"))
# 无旗标换到 rdm → 拒绝,source 不变。 # 无旗标换到 rdm → 拒绝,source 不变。
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844", denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", port,
env=env, check=False) env=env, check=False)
self.assertNotEqual(denied.returncode, 0) self.assertNotEqual(denied.returncode, 0)
self.assertIn("allow-source-switch", denied.stdout) self.assertIn("allow-source-switch", denied.stdout)
self.assertEqual(self.kv("sw1", "source"), old) self.assertEqual(self.kv("sw1", "source"), old)
# 带旗标允许换源,伴生就位。 # 带旗标允许换源,伴生就位。
ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844", ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", port,
"--allow-source-switch", env=env) "--allow-source-switch", env=env)
self.assertTrue(self.kv("sw1", "source").startswith("rdm@")) self.assertTrue(self.kv("sw1", "source").startswith("rdm@"))
self.assertIn("UI 就绪", ok.stdout) self.assertIn("UI 就绪", ok.stdout)
@@ -961,7 +967,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertIn("RDM 仓提交", result.stdout) self.assertIn("RDM 仓提交", result.stdout)
self.assertIn("rdm v1", result.stdout) self.assertIn("rdm v1", result.stdout)
# 部署 v1 → 远端推进 v2 → update 检测可更新并真更新(source 保持 rdm@ 轨道)。 # 部署 v1 → 远端推进 v2 → update 检测可更新并真更新(source 保持 rdm@ 轨道)。
self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18842", env=env) self.svc("rdm1", "deploy", "--from", "rdm", "--port", str(free_test_port()), env=env)
old = self.kv("rdm1", "source") old = self.kv("rdm1", "source")
(work / "marker").write_text("v2\n") (work / "marker").write_text("v2\n")
subprocess.run(["git", "add", "."], cwd=work, env=self.env, timeout=60, check=True) subprocess.run(["git", "add", "."], cwd=work, env=self.env, timeout=60, check=True)
+14 -7
View File
@@ -21,6 +21,7 @@ import shutil
import socket import socket
import sqlite3 import sqlite3
import subprocess import subprocess
import sys
import tempfile import tempfile
import time import time
import unittest import unittest
@@ -28,6 +29,9 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
SOURCE = Path(__file__).resolve().parents[1] SOURCE = Path(__file__).resolve().parents[1]
REAL = os.environ.get("SVC_TEST_REAL") == "1" REAL = os.environ.get("SVC_TEST_REAL") == "1"
@@ -165,7 +169,8 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
def test_U3_real_agent_identity_lifecycle_sqlite(self): def test_U3_real_agent_identity_lifecycle_sqlite(self):
"""真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定; """真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定;
capabilities v1 探测契约。""" capabilities v1 探测契约。"""
self.seed_real("rc1", 9471, self.real_bin) port = free_test_port()
self.seed_real("rc1", port, self.real_bin)
result = self.svc("rc1", "start") result = self.svc("rc1", "start")
self.assertIn("已启动", result.stdout) self.assertIn("已启动", result.stdout)
m = self.manifest("rc1") m = self.manifest("rc1")
@@ -178,7 +183,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
self.assertTrue(con.execute( self.assertTrue(con.execute(
"SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1) "SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1)
con.close() con.close()
self.assertTrue(self.http_ok(9471)) self.assertTrue(self.http_ok(port))
# capabilities v1 契约(探测无副作用形态) # capabilities v1 契约(探测无副作用形态)
cap = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"), cap = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
@@ -203,7 +208,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")], self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
"裸重启不得换身份") "裸重启不得换身份")
self.assertEqual(self.manifest("rc1")["instance_id"], iid) self.assertEqual(self.manifest("rc1")["instance_id"], iid)
self.assertTrue(self.http_ok(9471)) self.assertTrue(self.http_ok(port))
self.svc("rc1", "stop") self.svc("rc1", "stop")
@unittest.skipUnless(REAL_RDM_BIN, "真实 RDM 专项 NOT_RUN:需 SVC_REAL_RDM_BIN") @unittest.skipUnless(REAL_RDM_BIN, "真实 RDM 专项 NOT_RUN:需 SVC_REAL_RDM_BIN")
@@ -211,13 +216,14 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
"""真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 → """真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 →
领域层可初始化。""" 领域层可初始化。"""
rdm_bin = Path(REAL_RDM_BIN).resolve() rdm_bin = Path(REAL_RDM_BIN).resolve()
self.seed_real("rcr", 9473, rdm_bin) port = free_test_port()
self.seed_real("rcr", port, rdm_bin)
result = self.svc("rcr", "start") result = self.svc("rcr", "start")
self.assertIn("已启动", result.stdout) self.assertIn("已启动", result.stdout)
m = self.manifest("rcr") m = self.manifest("rcr")
iid = m["instance_id"] iid = m["instance_id"]
self.assertEqual(self.identity_row("rcr"), [(iid, "sqlite")]) self.assertEqual(self.identity_row("rcr"), [(iid, "sqlite")])
self.assertTrue(self.http_ok(9473)) self.assertTrue(self.http_ok(port))
# RDM 未知子命令明确退出(不落服务路径) # RDM 未知子命令明确退出(不落服务路径)
bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"), bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"),
"totally-bogus"], "totally-bogus"],
@@ -236,7 +242,8 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
env.update(ci_git_env()) env.update(ci_git_env())
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"), env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
"SVC_RDM_ORIGIN": RDM_ORIGIN}) "SVC_RDM_ORIGIN": RDM_ORIGIN})
result = self.svc("rcf", "deploy", "--from", "rdm", "--port", "9474", agent_port = free_test_port()
result = self.svc("rcf", "deploy", "--from", "rdm", "--port", str(agent_port),
env=env, check=False) env=env, check=False)
if result.returncode != 0: if result.returncode != 0:
log = self.runtime("rcf") / "svc.log" log = self.runtime("rcf") / "svc.log"
@@ -259,7 +266,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
else "rdm", "工作台内容") else "rdm", "工作台内容")
self.svc("rcf", "restart") self.svc("rcf", "restart")
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")]) self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
self.assertTrue(self.http_ok(9474)) self.assertTrue(self.http_ok(agent_port))
self.svc("rcf", "stop") self.svc("rcf", "stop")
self.svc("rcfweb", "stop") self.svc("rcfweb", "stop")