Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=096b74b4775e81cfb2864f98cae2e9fc9d9c1a44. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
@@ -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 确认替代,已如实标注。
|
||||
@@ -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]
|
||||
@@ -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
@@ -10,12 +10,16 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
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]
|
||||
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
|
||||
|
||||
@@ -666,7 +670,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
|
||||
"""
|
||||
old_sha, new_sha = self.make_src_repo()
|
||||
ports = {"a": 18841, "b": 18842}
|
||||
ports = {"a": free_test_port(), "b": free_test_port()}
|
||||
old_pids = {}
|
||||
for name, port in ports.items():
|
||||
d = self.runtime(name)
|
||||
@@ -897,7 +901,8 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
def test_rdm_source_deploy_end_to_end(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
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")
|
||||
# U2:组合产物入不可变 release,经 current 指针解析;插件空集、工作台随 release。
|
||||
active = d / "current"
|
||||
@@ -923,7 +928,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
def test_rdm_companion_missing_web_fails_deploy(self):
|
||||
remote, work = self.make_rdm_repo(with_web=False)
|
||||
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)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("ui-missing", result.stdout)
|
||||
@@ -934,18 +939,19 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
def test_source_switch_requires_flag(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
port = str(free_test_port())
|
||||
# 先以 local 源部署(内嵌 UI 桩一律 200)。
|
||||
self.svc("sw1", "deploy", "--from", "local", "--port", "18844")
|
||||
self.svc("sw1", "deploy", "--from", "local", "--port", port)
|
||||
old = self.kv("sw1", "source")
|
||||
self.assertTrue(old.startswith("local@"))
|
||||
# 无旗标换到 rdm → 拒绝,source 不变。
|
||||
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
|
||||
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", port,
|
||||
env=env, check=False)
|
||||
self.assertNotEqual(denied.returncode, 0)
|
||||
self.assertIn("allow-source-switch", denied.stdout)
|
||||
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)
|
||||
self.assertTrue(self.kv("sw1", "source").startswith("rdm@"))
|
||||
self.assertIn("UI 就绪", ok.stdout)
|
||||
@@ -961,7 +967,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertIn("RDM 仓提交", result.stdout)
|
||||
self.assertIn("rdm v1", result.stdout)
|
||||
# 部署 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")
|
||||
(work / "marker").write_text("v2\n")
|
||||
subprocess.run(["git", "add", "."], cwd=work, env=self.env, timeout=60, check=True)
|
||||
|
||||
@@ -21,6 +21,7 @@ import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
@@ -28,6 +29,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
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]
|
||||
|
||||
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):
|
||||
"""真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定;
|
||||
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")
|
||||
self.assertIn("已启动", result.stdout)
|
||||
m = self.manifest("rc1")
|
||||
@@ -178,7 +183,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
|
||||
self.assertTrue(con.execute(
|
||||
"SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1)
|
||||
con.close()
|
||||
self.assertTrue(self.http_ok(9471))
|
||||
self.assertTrue(self.http_ok(port))
|
||||
|
||||
# capabilities v1 契约(探测无副作用形态)
|
||||
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.manifest("rc1")["instance_id"], iid)
|
||||
self.assertTrue(self.http_ok(9471))
|
||||
self.assertTrue(self.http_ok(port))
|
||||
self.svc("rc1", "stop")
|
||||
|
||||
@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_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")
|
||||
self.assertIn("已启动", result.stdout)
|
||||
m = self.manifest("rcr")
|
||||
iid = m["instance_id"]
|
||||
self.assertEqual(self.identity_row("rcr"), [(iid, "sqlite")])
|
||||
self.assertTrue(self.http_ok(9473))
|
||||
self.assertTrue(self.http_ok(port))
|
||||
# RDM 未知子命令明确退出(不落服务路径)
|
||||
bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"),
|
||||
"totally-bogus"],
|
||||
@@ -236,7 +242,8 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
|
||||
env.update(ci_git_env())
|
||||
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
|
||||
"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)
|
||||
if result.returncode != 0:
|
||||
log = self.runtime("rcf") / "svc.log"
|
||||
@@ -259,7 +266,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
|
||||
else "rdm", "工作台内容")
|
||||
self.svc("rcf", "restart")
|
||||
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("rcfweb", "stop")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user