51 changed files with 1682 additions and 691 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ docker compose -f deploy/docker-compose.yml up -d
- [产品需求](docs/PRD.md)
- [开发说明](docs/DEVELOPMENT.md)
- [运维手册](docs/OPS.md)
- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 按子项记结果:通过 19,部分通过 4;未测子项见对照表与 [OPS.md](docs/OPS.md) 第 9 节)
- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 按子项记结果:通过 21,部分通过 2;未测子项见对照表与 [OPS.md](docs/OPS.md) 第 9 节)
- [开发任务](docs/TASKS.md)
- [与文档的偏差](docs/DEVIATIONS.md)
+3 -1
View File
@@ -335,7 +335,9 @@ func runServe(ctx context.Context, cfg config.Config) error {
drainCancel()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = brk.Shutdown(shutCtx)
if shutErr := brk.Shutdown(shutCtx); shutErr != nil && !errors.Is(shutErr, context.DeadlineExceeded) && !errors.Is(shutErr, context.Canceled) {
slog.Error("broker shutdown", "err", shutErr)
}
shutCancel()
secondDrain := drainBudget - time.Since(drainStart)
+90
View File
@@ -1718,3 +1718,93 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是
- 原因:原先 12 项备注写着未穷尽仍标通过,交付说明写成「通过 23」。
- 备选方案:为每个未测子项补验收用例(本波不做,避免为变绿放松断言)。
- 影响:汇总改为通过 19、部分通过 4(F03/F08/F21/F22)、失败 0。F19 仍引用仓库内 SDK 清单、本波不重跑。
### 复审修复 R3-01
1. **推送 worker 漏唤醒:定时分发回执、超限后续、撤回腾窗**
- 日期:2026-09-30
- 原条款:Gitea #65;推送 worker 只在 `WakePush` 时跑一轮 `PushPending`。
- 实际做法:`dispatchDueBatch` 本条已分发时除 pending 接收方外始终 `WakePush` 发送方;`PushPending` 遇 `too_large` 等跳过且本轮未占满窗口时再 `WakePush` 当前接收方(不在同一次递归扫表);`Recall` 成功改成 `recalled` 的接收方各 `WakePush` 一次,已推送撤回仍走 `flushRevokes`。不改 `PublishDown` 签名。
- 原因:终态回执、超限后的后续 pending、撤回腾出窗口后都依赖再唤醒,否则空闲在线端收不到。
- 备选方案:在同一次 `PushPending` 里循环扫完整 pending 表(否决,指令要求合并唤醒下一轮)。
- 影响:仅 `internal/app/message/`;相关单测见 `review_r3_65_test.go`。
### 复审修复 R3-03
1. **写队列满时 Close 不再与 enqueue 死锁**
- 日期:2026-09-30
- 原条款:issue #67;停机需 Drain/Close 在超时内返回;已入队任务仍由写 goroutine 处理完。
- 实际做法:Queue 增加 closing 信号。enqueue 在持 sendMu.RLock 时 select 同时等待 q.ch、closing 与 ctx.Done(),通道满时不再无限阻塞。Close 先 Swap(closed) 并 close(closing) 唤醒在途发送方释放读锁,再在写锁内 close(q.ch),最后等 loop 退出。不向已关闭 channel 发送。
- 原因:通道满时并发 Do 占着读锁堵在发送上,Close 的写锁拿不到,停机卡死;未提交的在途写也会丢。
- 备选方案:入队改为非阻塞,满则立即 ErrBusy(否决:改变背压语义,正常高峰会误伤提交)。
- 影响:仅 internal/store/queue.go 与测试;不改迁移编号。
### 复审修复 R3-04
- 日期:2026-09-30
- 原条款:Gitea #68;`dispatchSend` 在 `PublishUp` 失败且未停止重连时清 inflight 后静默 return,`Send` 永久挂起。
- 实际做法:失败且未 `stopReconnect` 时保留 id/body/send_at_ms,经 `regenerateSendLocked` 换新 rid,清本次 inflight 并 `drainSendQueue` 继续泵;已停止重连时仍 `finishSend` 返回错误。
- 原因:条目已不在途,重连时的 `requeueInflightLocked` 不会捡回,调用方一直等 `result`。
- 备选方案:失败即 `finishSend` 报错(否决,与断线/限速重交语义不一致);只换 rid 不唤醒队列(否决,等同 JS #69)。
- 影响:仅 `sdk/go`;假传输可模拟 send 发布失败一次。
### 复审修复 R3-05
1. **JS SDK 传输失败后须再泵发送队列**
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节 / 附录 SDK 行为约定:发送在途失败后换新 `rid` 并重交;`id` / 正文 / `send_at_ms` 不变。Gitea #69。
- 实际做法:`dispatchSend` 中 `publishUp` 抛出非协议 `APIError` 时,若未 `stopReconnect` 则清 inflight、换新 `rid` 后立刻 `drainSendQueue`;若已 `stopReconnect` 则 `finishSendErr` 结束本次 `send`。不额外再乘一次抖动。
- 原因:原先只换 `rid` 并 `return`,连接未断时 `hello` 不再走,队列停泵,`send()` Promise 永不结束。
- 备选方案:按 `rate_limited` 同一条的退避再泵(否决本波,连接仍在线时立即重交更贴切,且避免与已有等待叠乘抖动)。
- 影响:仅 `sdk/js`;假传输失败一次后会再次上行且 `rid` 已变。
### 复审修复 R3-06
1. **Python/Java 首次握手失败应停止重连;Java rate_limited 在途计数只减一次**
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 9 节附录「首次连接超时或握手失败应停止重连,并向 connect() 返回未连接」;issue #70。
- 实际做法:`sdk/python` 的 `connect()` 在 `_handshake_error` 或非 ONLINE 终态时置 `_stop_reconnect`/`_want_connected=False`;内部连接/握手超时改用 `not_connected`(不再用 `busy`)。`sdk/java` 的 `connectSync` 在 `handshakeError` 时同样置 `stopReconnect`;`rate_limited` 路径去掉第二次 `inflightSends--`,只换新 rid 再排队。Python `transport.py` 将 paho 改为惰性导入,便于本机无 paho 时仍跑 FakeTransport 单测。不改 Go/JS 重连公式。
- 原因:原先抛错后工作线程仍按退避重连;Java 限速把在途计数减了两次。
- 备选方案:在后台 `_attempt_connect`/`attemptConnect` 内首次失败即停(否决:应用再次 `connect()` 才应恢复,标志应在对外 `connect` 失败路径统一置位)。
- 影响:仅 `sdk/python`、`sdk/java`;应用需再次调用 `connect()` 才会重连。
### 复审修复 R3-07
1. **客户端建群事务内复核创建者**
- 日期:2026-09-30
- 原条款:Gitea #71。`group.Create` 在 `Queue.Do` 里直接 `insertMemberTx` 群主,不跑 `endpointCheckTx`;停用/删除与建群抢在事务外对话密码窗口时,可插入已停用甚至刚删除的 `owner_id`。
- 实际做法:`Create` 同一写事务里,插入群与群主成员之前对 `actorID` 做与 `createAdmin` 相同的 `endpointCheckTx`;已停用 `endpoint_disabled`,不存在 `invalid_target`,且不插入群。不改 `emit`、不改迁移编号。
- 原因:成员侧已有事务内复核,群主侧缺对称校验,竞态会留下非法群主。
- 备选方案:事务外再查一次创建者(否决,无法覆盖密码窗口与写事务之间的竞态)。
- 影响:创建者在进入写事务前被停用/删除时建群失败且无新群行;与后台建群错误码对齐。
### 复审修复 R3-08
1. **后台加人审计按实际插入数,全跳过不记 ok**
- 日期:2026-09-30
- 原条款:Gitea #72;`AdminAddMembers` 对已是成员与请求内重复编号静默跳过;审计用 `len(member_ids)-len(failed)` 当成功数。
- 实际做法:`handleGroupAddMembers` 用加人前后 `group_members` 行数差得到 `added`;审计 `result` 用 `added` 与 `failed`(全跳过且无失败记 `noop`,不记 `ok`);响应增加 `added`,`failed` 仍只含真正失败项。前端按 `added` 提示,已是成员不改成错误码。
- 原因:全是已有成员时旧逻辑审计成 ok、界面提示已加人,实际插入 0 行。
- 备选方案:把已是成员写入 `failed`(否决,会改变客户端错误语义);扩展 `group.AddResult` 返回插入列表(可后续做,本波不改身份线接口)。
- 影响:管理 API 加人成功体多 `added` 字段;契约文档示例仍写 `{"failed":[]}`,以本偏差为准。`added` 取写事务内实际插入数(`AddResult.Added`,不进端协议 JSON),不用事务外两次 COUNT 的差,避免并发加人/踢人把别人的变更算进本请求。
### 复审修复 R3-02
1. **积压时 fatal/logout 与停机 0x8B 须等本帧写出**
- 日期:2026-09-30
- 原条款:Gitea #66;B-04 / B-08。
- 实际做法:带断开的下行帧用本帧 `OnPacketSent` 完成信号(优先 packet id,否则按载荷匹配),不再用连接级 `sentPub` 总数。`Shutdown` 在 ctx 未取消时先等下行队列与 `wirePending` 排空,再 `DisconnectClient` 发 `0x8B` 并在截止前等连接拆掉;ctx 已取消则发完即 `Close`。`serve` 仍给 5 秒预算并记录非超时错误。未合 `feat/fix-3-downlink-deadlock`。
- 原因:前面 PUBLISH 的 `OnPacketSent` 会让总数等待提前返回;`Shutdown` 对 ctx 非阻塞 select 使 5 秒预算用不上,有 outbound 积压时 `0x8B` 只进 outbuf 随 `Stop` 丢掉。
- 备选方案:恢复固定 `Sleep`(否决);改 `PublishDown` 签名(否决)。
- 影响:队列/outbound 有积压时 fatal、logout 先到客户端再断开;停机在预算内尽量发出 `0x8B`,超时返回 ctx 错误而非空等。
- 补强:`sendOne` 从取出帧到返回前记 `inSend`,排空等待把它算上(含等大帧名额、尚未 `wirePending++` 的窗口)。有截止时间时排空最多用到截止前 1 秒,剩下的时间留给 `0x8B` 写出;排空没完成也不会因此跳过这段等待。
### 阶段 3 测试清理
1. **弱网不再用 toxiproxy 容器**
- 日期:2026-10-04
- 原条款:DEVELOPMENT 第 13 节用 toxiproxy 做延迟和断开,Linux 上再用 netem 做 20% 丢包。
- 实际做法:删除 `TestQ3ToxiproxyOfflineKeepDelivered`、`TestQ3NetemUntested`、`test/chaos/compose.yml` 和 netem 旁路脚本。崩溃续传仍由 `TestQ3CrashSubmitThenRestart` 覆盖。20% 丢包在 Linux 虚拟机里用 `tc netem` 测,不进 `task check`。
- 原因:测试环境改为虚拟机,不再维护 Docker 弱网编排。
- 备选方案:保留 toxiproxy 用例(否决,与当前测试方式重复)。
- 影响:F08c 在 netem 结果写入前记为未测。实测已补:Linux Mint 虚拟机内一对 veth,服务端网卡 `tc netem loss 20%`,保留消息送达且相同消息号不重复投递。1000 个在线端分页拉全目录耗时 30ms,低于 1 秒。真拔网线仍未测。
+5 -5
View File
@@ -127,10 +127,10 @@ curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/
## 9. 验收与仍跳过的长时项
F01–F23 验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 19,部分通过 4,失败 0,未测 0)。整项通过要求该行子项都有证据。下列子项**未测**,不得宣称已通过:
F01–F23 验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 21,部分通过 2,失败 0,未测 0)。整项通过要求该行子项都有证据。下列子项未测,不得宣称已通过:
- F03:1000 端全表 1 秒内返回、真拔网线后心跳超时离线
- F08:Linux netem 20% 丢包(本机 Windows)
- F21:裸 TCP MQTT 登录收发(验收只走 WebSocket)
- F22:Docker 全量冒烟
- F03d:真拔网线后心跳超时离线(现有证据是关掉连接)
- F22f:镜像启动冒烟(二进制启动已在 Linux 虚拟机上通过健康检查)
- 压测:1000 连接保持 10 分钟、每秒 200 条
已测:1000 端在线分页拉全目录 30ms;虚拟机 veth 上 netem 20% 丢包后保留消息送达且相同消息号不重复投递;裸 TCP MQTT 登录、收、确认、发。
+19 -18
View File
@@ -2,10 +2,10 @@
| 项 | 内容 |
|---|---|
| 版本 | 0.1.0 |
| 日期 | 2026-09-30 |
| 功能合入 tip | `2ebcb6e1b85a8997c48de2711b013c83fa0440fc`(W4 + Q4 已进 main) |
| 发布标签 | `v0.1.0`、`sdk/go/v0.1.0`(指向含本说明的提交;以 `git rev-parse v0.1.0` 为准) |
| 版本 | 0.1.1 |
| 日期 | 2026-10-04 |
| 功能合入 tip | 含本说明的 `main` 提交(以 `git rev-parse v0.1.1` 为准) |
| 发布标签 | `v0.1.1`、`sdk/go/v0.1.1`。已有的 `v0.1.0` 与 `sdk/go/v0.1.0` 仍指向更早提交,不移动。 |
| 对应 | [PRD.md](./PRD.md)、[DEVELOPMENT.md](./DEVELOPMENT.md)、[TASKS.md](./TASKS.md) |
## 1. 已完成能力
@@ -17,7 +17,7 @@
## 2. F01–F23 验收结果
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(T-02 按子项重写,见 issue #63)。对照表汇总:**通过 19,部分通过 4,失败 0,未测 0**。整项「通过」要求该行 PRD 一句话里的子项都有测试证据;不得把部分覆盖标成通过。
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)。对照表汇总:**通过 21,部分通过 2,失败 0,未测 0**。整项「通过」要求该行 PRD 一句话里的子项都有测试证据。
### 通过
@@ -29,6 +29,7 @@
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 |
| F09 | 保留时间从发送时刻起算,超时过期 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 |
| F11 | 发送方离线后到点仍发送 |
@@ -41,18 +42,17 @@
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 |
| F19 | 四种 SDK 通过同一清单(仓库内清单测试,本波未重跑全量) |
| F20 | 裸 MQTT WebSocket 能登录、收、确认、发 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 |
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 |
### 部分通过
| 编号 | 已覆盖 | 仍未测 |
|---|---|---|
| F03 | 断开后变离线、目录可列出 | 1000 端全表 1s、真拔网线心跳超时 |
| F08 | 重启续传、应用层同号去重、toxiproxy 弱网 | Linux netem 20% 丢包 |
| F21 | 单端口 HTTP/WS/注册、后台分离端口 | 裸 TCP MQTT 登录收发 |
| F22 | init/健康检查、备份、迁移前备份、证书重载、/metrics | Docker 全量冒烟 |
| F03 | 断开后变离线、目录可列出、1000 端在线全表 30ms | 真拔网线后心跳超时离线 |
| F22 | init/健康检查、备份、迁移前备份、证书重载、/metrics | 镜像启动冒烟在发布构建时补记 |
压测 1000 连接保持 10 分钟、每秒 200 条仍未跑(见 OPS 第 9 节)。
F08 弱网与 F21 裸 TCP 已有证据,整项为通过。1000 连接保持 10 分钟、每秒 200 条仍未跑。
### 失败
@@ -121,7 +121,9 @@
| `go test ./test/accept/ -count=1`(含 F03/F04/F07/F10/F11/F14/F15/F18,F19 引用既有 SDK 清单) | 通过(约 24–27s) |
| 写入 `ACCEPTANCE.md` / `q2_results.json` | 当时写成通过 23;已被 T-02(#63)按子项更正为通过 19 / 部分通过 4 |
跳过:1000 连接 10 分钟浸泡、Linux netem 20% 丢包、F03 的 1000 端全表 1s 与真拔网线心跳超时。
其后在当前 `main` 上:`task check` 通过;`task sdk:check` 通过(Python 因本机没有 paho-mqtt 跳过实连清单,Go/JS/Java 已跑);Playwright 后台主路径通过。虚拟机上补了裸 TCP、1000 端目录 30ms、netem 20% 丢包和二进制健康检查。仍未跑:真拔网线、1000 连接保持 10 分钟、每秒 200 条。
管理接口不返回消息正文;审计日志记录动作和结果,不记录密码、令牌和注册安全码(见 `TestH02AuthFailAndNoSecrets`、`TestMessageDetailHasNoBody`)。`admin init` 把新密码只向操作者的标准输出打印一次。依赖以 Go 标准库、mochi-mqtt、modernc.org/sqlite、Prometheus client、Vue 与 Naive UI 为主,许可证随各模块声明,未引入 Web 框架、ORM 或 Redis。
## 5. 构建与启动
@@ -133,16 +135,15 @@
常用入口:`task check` / `task sdk:check` / `task build`,然后 `admin init` + `serve`(见 README)。发布前须执行 `task sdk:check`。
## 6. 未发布 SDK 包与镜像的原因
## 6. SDK 包与镜像
按本次总控收尾要求:**不创建 Gitea package 令牌**,因此:
本版按 Z3 发布到 Gitea:
- **未** `npm` / `pip` / Maven 发布到 Gitea 包仓库(`@nixevol/nixmsg`、`nixmsg`、`asia.asio.nixmsg:nixmsg-sdk`)
- **未** `docker push`(含 `git.asio.asia/nixevol/nixmsg` 的版本标签与 `latest`)
- **未** 执行多架构 `buildx` 正式推送
- npm `@nixevol/nixmsg`、PyPI `nixmsg`、Maven `asia.asio.nixmsg:nixmsg-sdk`,包版本 0.1.0
- 镜像 `git.asio.asia/nixevol/nixmsg` 的 `0.1.1` 与 `latest`
有 package 读写令牌且负责人授权后,再按 [TASKS.md](./TASKS.md) 第 3 节与 Z3、以及 README / OPS 中的发布命令补做。
Git 标签用 0.1.1,不移动已有的 `v0.1.0`。若某一步因工具或架构失败,以发布时的补记为准。
## 7. 标签
在本交付说明提交(含 `docs/RELEASE.md`)上打附注标签 `v0.1.0` 与 `sdk/go/v0.1.0` 并推送到 `origin`(若远端已存在同名标签则不覆盖,见当时操作记录)。
在含本说明的提交上打附注标签 `v0.1.1` 与 `sdk/go/v0.1.1` 并推送。不移动已有的 `v0.1.0` 与 `sdk/go/v0.1.0`。
+12 -5
View File
@@ -325,15 +325,22 @@ func (h *Handler) handleGroupAddMembers(w http.ResponseWriter, r *http.Request)
if failed == nil {
failed = []group.MemberFail{}
}
okN := len(req.MemberIDs) - len(failed)
if okN < 0 {
okN = 0
added := res.Added
if added < 0 {
added = 0
}
h.auditPD(p, "group_add_members", id, batchAuditResult(okN, len(failed)), ip, map[string]any{
// 已是成员/请求内重复编号会静默跳过,不算失败;审计用实际插入数,避免全跳过写成 ok。
result := batchAuditResult(added, len(failed))
if added == 0 && len(failed) == 0 {
result = "noop"
}
h.auditPD(p, "group_add_members", id, result, ip, map[string]any{
"members": req.MemberIDs,
"failed": failed,
"added": added,
})
httpx.WriteOK(w, map[string]any{"failed": failed})
httpx.WriteOK(w, map[string]any{"failed": failed, "added": added})
}
func (h *Handler) handleGroupRemoveMember(w http.ResponseWriter, r *http.Request) {
+87 -3
View File
@@ -262,6 +262,7 @@ func TestH02BatchPartialImportAndGroups(t *testing.T) {
insertEndpoint(t, db, "keep-1", "admin", true, false)
insertEndpoint(t, db, "alice", "admin", true, false)
insertEndpoint(t, db, "bob", "admin", true, false)
insertEndpoint(t, db, "carol", "admin", true, false)
res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/endpoints/batch",
`{"ids":["keep-1","missing-ep"],"action":"disable"}`,
@@ -304,11 +305,24 @@ func TestH02BatchPartialImportAndGroups(t *testing.T) {
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members",
`{"member_ids":["keep-1","ghost-ep"]}`, csrf())
`{"member_ids":["carol","ghost-ep"]}`, csrf())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("add members: %d %+v", res.StatusCode, env)
}
var addBody struct {
Failed []any `json:"failed"`
Added int `json:"added"`
}
if err := json.Unmarshal(env.Data, &addBody); err != nil {
t.Fatal(err)
}
if addBody.Added != 1 {
t.Fatalf("add response added=%d want 1", addBody.Added)
}
if len(addBody.Failed) != 1 {
t.Fatalf("add response failed=%v want 1", addBody.Failed)
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/transfer",
`{"endpoint_id":"bob"}`, csrf())
@@ -346,8 +360,8 @@ func TestH02BatchPartialImportAndGroups(t *testing.T) {
if ad == nil {
t.Fatalf("add members missing detail: %v", add)
}
if add["result"] != "partial" && add["result"] != "failed" && add["result"] != "ok" {
t.Fatalf("add members result=%v", add["result"])
if add["result"] != "partial" {
t.Fatalf("add members result=%v want partial", add["result"])
}
if _, ok := ad["members"]; !ok {
t.Fatalf("add members missing members: %v", ad)
@@ -355,6 +369,9 @@ func TestH02BatchPartialImportAndGroups(t *testing.T) {
if _, ok := ad["failed"]; !ok {
t.Fatalf("add members missing failed: %v", ad)
}
if ad["added"] != float64(1) {
t.Fatalf("add members added=%v want 1", ad["added"])
}
tr := lastAuditByAction(t, recs, "group_transfer")
td, _ := tr["detail"].(map[string]any)
@@ -364,3 +381,70 @@ func TestH02BatchPartialImportAndGroups(t *testing.T) {
assertNoSecrets(t, auditBuf.String(), "csv-pass-secret-1", "csv-pass-secret-2", testPassword)
}
func TestH02AddMembersAllSkippedAuditNotOK(t *testing.T) {
_, auditBuf, db, srv, client := setupH02(t)
login(t, client, srv.URL)
insertEndpoint(t, db, "alice", "admin", true, false)
insertEndpoint(t, db, "bob", "admin", true, false)
res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups",
`{"name":"一组","owner_id":"alice","member_ids":["bob"]}`, csrf())
env := decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("group create: %d %+v", res.StatusCode, env)
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(env.Data, &created); err != nil {
t.Fatal(err)
}
auditBuf.Reset()
var before int
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&before); err != nil {
t.Fatal(err)
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members",
`{"member_ids":["bob","bob","alice"]}`, csrf())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("add members: %d %+v", res.StatusCode, env)
}
var body struct {
Failed []any `json:"failed"`
Added int `json:"added"`
}
if err := json.Unmarshal(env.Data, &body); err != nil {
t.Fatal(err)
}
if len(body.Failed) != 0 {
t.Fatalf("failed=%v want empty (already members are not errors)", body.Failed)
}
if body.Added != 0 {
t.Fatalf("added=%d want 0", body.Added)
}
var after int
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&after); err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("member rows before=%d after=%d want unchanged", before, after)
}
recs := parseSlogJSON(t, auditBuf)
add := lastAuditByAction(t, recs, "group_add_members")
if add["result"] == "ok" {
t.Fatalf("audit result must not be ok when all skipped: %v", add)
}
ad, _ := add["detail"].(map[string]any)
if ad == nil {
t.Fatalf("missing detail: %v", add)
}
if ad["added"] != float64(0) {
t.Fatalf("detail.added=%v want 0", ad["added"])
}
}
+11 -2
View File
@@ -147,6 +147,15 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
var inserted []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
if e := endpointCheckTx(tx, actorID); e != nil {
if protoCode(e) == protocol.CodeInvalidTarget {
return errCode(protocol.CodeInvalidTarget, "owner not found")
}
if protoCode(e) == protocol.CodeEndpointDisabled {
return errCode(protocol.CodeEndpointDisabled, "owner disabled")
}
return e
}
var exists int
qErr := tx.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, gid).Scan(&exists)
if qErr == nil {
@@ -611,7 +620,7 @@ func (a *App) AdminAddMembers(ctx context.Context, groupID string, memberIDs []s
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
}
if len(toAdd) == 0 {
return AddResult{Failed: failed}, nil
return AddResult{Failed: failed, Added: 0}, nil
}
now := a.nowMs()
var inserted []string
@@ -655,7 +664,7 @@ func (a *App) AdminAddMembers(ctx context.Context, groupID string, memberIDs []s
for _, id := range inserted {
a.emit(ctx, notify, groupID, eventMemberAdded, id, now)
}
return AddResult{Failed: failed}, nil
return AddResult{Failed: failed, Added: len(inserted)}, nil
}
func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, members []protocol.GroupMemberIn) (CreateResult, error) {
+100
View File
@@ -604,6 +604,106 @@ func TestU02CreateDedupesMembers(t *testing.T) {
}
}
// raceTalk 在对话密码校验成功后执行一次 mutate,模拟事务外密码窗口里创建者被停用/删除。
type raceTalk struct {
inner group.TalkGate
mutate func()
once sync.Once
}
func (r *raceTalk) CheckTalkPasswordForJoin(ctx context.Context, actorID, targetID, talkPassword, remoteIP string) error {
err := r.inner.CheckTalkPasswordForJoin(ctx, actorID, targetID, talkPassword, remoteIP)
if err == nil && r.mutate != nil {
r.once.Do(r.mutate)
}
return err
}
func TestR307CreateRejectsActorDisabledOrDeletedBeforeWrite(t *testing.T) {
t.Parallel()
cases := []struct {
name string
code string
mutate func(ctx context.Context, t *testing.T, idApp *identity.App, db *store.DB)
}{
{
name: "disabled",
code: protocol.CodeEndpointDisabled,
mutate: func(ctx context.Context, t *testing.T, idApp *identity.App, _ *store.DB) {
t.Helper()
if err := idApp.Disable(ctx, "alice"); err != nil {
t.Fatal(err)
}
},
},
{
name: "deleted",
code: protocol.CodeInvalidTarget,
mutate: func(ctx context.Context, t *testing.T, idApp *identity.App, _ *store.DB) {
t.Helper()
if err := idApp.Delete(ctx, "alice"); err != nil {
t.Fatal(err)
}
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, err := store.Open(filepath.Join(t.TempDir(), "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
fixed := time.UnixMilli(1_700_000_000_000)
locks := auth.NewLoginLocks()
idApp := identity.New(identity.Config{
DB: db, Hash: auth.NewStubHashPool(), Locks: locks,
Sessions: auth.NewSessionTokens(),
Now: func() time.Time { return fixed },
})
gate := &raceTalk{inner: idApp}
gApp := group.New(group.Config{
DB: db, Talk: gate, MaxGroupMembers: 1000,
Now: func() time.Time { return fixed }, DefaultRemoteIP: "1.1.1.1",
})
ctx := context.Background()
insertEP(t, db, "alice", 1)
insertEP(t, db, "bob", 1)
if setErr := idApp.SelfSetTalkPassword(ctx, "bob", "secret"); setErr != nil {
t.Fatal(setErr)
}
gate.mutate = func() { tc.mutate(ctx, t, idApp, db) }
var before int
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM groups`).Scan(&before); qErr != nil {
t.Fatal(qErr)
}
_, err = gApp.Create(ctx, "alice", &protocol.GroupCreate{
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1",
ID: "g_r307_" + tc.name, Name: "竞态群",
Members: []protocol.GroupMemberIn{{ID: "bob", TalkPassword: "secret"}},
})
if protoCode(err) != tc.code {
t.Fatalf("want %s got %v", tc.code, err)
}
var after int
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM groups`).Scan(&after); qErr != nil {
t.Fatal(qErr)
}
if after != before {
t.Fatalf("groups leaked: before=%d after=%d", before, after)
}
var exists int
qErr := db.Read.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, "g_r307_"+tc.name).Scan(&exists)
if !errors.Is(qErr, sql.ErrNoRows) {
t.Fatalf("expected no group row, got exists=%d err=%v", exists, qErr)
}
})
}
}
func TestU02AdminCreateOwnerMustExistAndEnabled(t *testing.T) {
t.Parallel()
gApp, _, _, db, down := setup(t)
+2
View File
@@ -28,6 +28,8 @@ type CreateResult struct {
// AddResult 是加人结果。
type AddResult struct {
Failed []MemberFail `json:"failed,omitempty"`
// Added 是本请求在写事务内实际插入的人数。不进协议 JSON,供后台审计使用。
Added int `json:"-"`
}
// ListItem 是 group.list 一项。
+5
View File
@@ -90,6 +90,7 @@ func (a *App) Recall(ctx context.Context, senderID string, req *protocol.Recall)
nowMs := a.now().UnixMilli()
var data protocol.RecallData
var revokes []revokeJob
var wakeReceivers []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
var seq int64
var state string
@@ -156,6 +157,7 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
continue
}
recalled++
wakeReceivers = append(wakeReceivers, p.ep)
if p.pushed.Valid && p.pushed.String != "" {
revokes = append(revokes, revokeJob{
endpointID: p.ep,
@@ -190,6 +192,9 @@ SELECT COUNT(*) FROM deliveries WHERE seq = ? AND state IN ('expired','dropped',
a.pendingRevoke = append(a.pendingRevoke, revokes...)
a.mu.Unlock()
a.flushRevokes(ctx)
for _, ep := range wakeReceivers {
a.WakePush(ep)
}
return data, nil
}
+8
View File
@@ -127,6 +127,7 @@ LIMIT ?`, nowMs, limit)
}
mu.Lock()
n++
wake[d.senderID] = struct{}{}
mu.Unlock()
rows2, qErr := a.db.Read.QueryContext(ctx, `
SELECT DISTINCT endpoint_id FROM deliveries WHERE seq = ? AND state = 'pending'`, d.seq)
@@ -203,8 +204,10 @@ LIMIT ?`, endpointID, room)
}
var toClaim []pushItem
skipped := false
for _, it := range items {
if it.body == nil {
skipped = true
continue
}
msg := protocol.Msg{
@@ -227,6 +230,7 @@ LIMIT ?`, endpointID, room)
return rejErr
}
a.WakePush(it.senderID)
skipped = true
continue
}
it.payload = payload
@@ -252,6 +256,10 @@ LIMIT ?`, endpointID, room)
a.observeDispatchToPush(it.sendAt, nowMs)
}
}
// 超限等跳过未占满窗口时再唤醒本端,让 worker 下一轮取后续 pending(不在本轮递归扫表)。
if skipped && len(claimed) < room {
a.WakePush(endpointID)
}
return a.pushReceipts(ctx, endpointID, connID, nowMs)
}
+180
View File
@@ -0,0 +1,180 @@
package message
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/protocol"
)
// 定时到点且接收方离线成终态时,在线发送方经 WakePush 拿到回执。
func TestR365DispatchDueWakesSenderForReceipt(t *testing.T) {
t.Parallel()
e := openDeliveryEnv(t, nil)
insertEndpoint(t, e.db, "alice", "", 1, 0)
insertEndpoint(t, e.db, "bob", "", 1, 0)
_ = e.db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
_, err := tx.Exec(`UPDATE endpoints SET offline_since = ? WHERE id=?`, e.nowMs-120_000, "bob")
return err
})
ctx := context.Background()
aliceConn := LiveConn{ConnID: "c-alice", Ready: true}
e.conns.Set("alice", aliceConn)
if err := e.app.OnHandshakeComplete(ctx, "alice", aliceConn); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { e.app.stopPushWorker("alice", "") })
delay := int64(5000)
req := baseSend("due-rcpt", "bob")
req.DelayMs = &delay
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, req); err != nil {
t.Fatal(err)
}
st, _ := e.msgState("alice", "due-rcpt")
if st != StateScheduled {
t.Fatalf("want scheduled got %s", st)
}
if e.down.FilterType(protocol.TypeReceipt) != 0 {
t.Fatal("receipt before due")
}
e.setNow(e.nowMs + 5000)
if _, err := e.app.DispatchDue(ctx, e.nowMs, 10); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if e.down.FilterType(protocol.TypeReceipt) > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("sender got no receipt after scheduled dispatch, receipts=%d", e.down.FilterType(protocol.TypeReceipt))
}
// 窗口内首条超限拒收后,同连接后续小消息仍被 worker 推送。
func TestR365TooLargeThenSmallContinuesPush(t *testing.T) {
t.Parallel()
e := openDeliveryEnv(t, func(l *Limits) { l.DeliveryWindow = 1 })
insertEndpoint(t, e.db, "alice", "", 1, 0)
insertEndpoint(t, e.db, "bob", "", 1, 0)
ctx := context.Background()
// 整帧上限:小消息能过,大正文整帧超限被拒。
bobConn := LiveConn{ConnID: "c-bob", Ready: true, MaxReceiveBytes: 200}
e.conns.Set("bob", bobConn)
if err := e.app.OnHandshakeComplete(ctx, "bob", bobConn); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { e.app.stopPushWorker("bob", "") })
big := baseSend("big-skip", "bob")
b := make([]byte, 400)
for i := range b {
b[i] = 'A'
}
big.Body.Data = string(b)
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, big); err != nil {
t.Fatal(err)
}
small := baseSend("small-ok", "bob")
small.Body.Data = "hi"
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, small); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
foundSmall := false
for _, p := range e.down.Snapshots() {
if payloadType(p.Payload) != protocol.TypeMsg {
continue
}
var m struct {
ID string `json:"id"`
}
_ = json.Unmarshal(p.Payload, &m)
if m.ID == "small-ok" {
foundSmall = true
}
}
if foundSmall {
seqBig := e.seqOf("alice", "big-skip")
st, reason := e.deliveryState(seqBig, "bob")
if st != DeliveryRejected || reason != ReasonTooLarge {
t.Fatalf("big: %s/%s", st, reason)
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("small msg not pushed after too_large; msgs=%d snapshots=%d",
e.down.FilterType(protocol.TypeMsg), len(e.down.Snapshots()))
}
// 撤回已推在途消息后,同连接更晚的 pending 继续推。
func TestR365RecallFreesWindowForLaterPending(t *testing.T) {
t.Parallel()
e := openDeliveryEnv(t, func(l *Limits) { l.DeliveryWindow = 1 })
insertEndpoint(t, e.db, "alice", "", 1, 0)
insertEndpoint(t, e.db, "bob", "", 1, 0)
ctx := context.Background()
bobConn := LiveConn{ConnID: "c-bob", Ready: true}
e.conns.Set("bob", bobConn)
if err := e.app.OnHandshakeComplete(ctx, "bob", bobConn); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { e.app.stopPushWorker("bob", "") })
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("first-in-flight", "bob")); err != nil {
t.Fatal(err)
}
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("second-wait", "bob")); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if e.down.FilterType(protocol.TypeMsg) >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
if e.down.FilterType(protocol.TypeMsg) < 1 {
t.Fatal("first msg not pushed")
}
seq1 := e.seqOf("alice", "first-in-flight")
var pushed sql.NullString
_ = e.db.Read.QueryRow(`SELECT pushed_conn FROM deliveries WHERE seq=? AND endpoint_id='bob'`, seq1).Scan(&pushed)
if !pushed.Valid {
t.Fatal("first not in-flight")
}
if _, err := e.app.Recall(ctx, "alice", &protocol.Recall{
V: protocol.Version, Type: protocol.TypeRecall, RID: "r1", ID: "first-in-flight",
}); err != nil {
t.Fatal(err)
}
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, p := range e.down.Snapshots() {
if payloadType(p.Payload) != protocol.TypeMsg {
continue
}
var m struct {
ID string `json:"id"`
}
_ = json.Unmarshal(p.Payload, &m)
if m.ID == "second-wait" {
return
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("second pending not pushed after recall; msgs=%d", e.down.FilterType(protocol.TypeMsg))
}
+179
View File
@@ -87,6 +87,50 @@ func TestPublishThenDisconnectWritesThenCloses(t *testing.T) {
}
}
func TestPublishThenDisconnectAfterQueuedFrame(t *testing.T) {
b, w, done := startTCPClient(t, "ep-ptd-q")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-ptd-q", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-ptd-q"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-ptd-q")
first := []byte(`{"v":1,"type":"msg","id":"queued-ahead"}`)
fatal := []byte(`{"v":1,"type":"fatal","reason":"disabled"}`)
if err := b.PublishDown(context.Background(), "ep-ptd-q", "", first, port.PublishOpts{QoS: 1}); err != nil {
t.Fatal(err)
}
if err := b.PublishThenDisconnect(context.Background(), "ep-ptd-q", "", fatal, 1, port.DisconnectFatal); err != nil {
t.Fatal(err)
}
gotFirst := readDownPayload(t, w, 3*time.Second)
if !bytes.Equal(gotFirst, first) {
t.Fatalf("first got %s", gotFirst)
}
gotFatal := readDownPayload(t, w, 3*time.Second)
if !bytes.Equal(gotFatal, fatal) {
t.Fatalf("fatal got %s want %s (disconnected before fatal frame)", gotFatal, fatal)
}
_ = w.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 64)
n, err := io.ReadAtLeast(w, buf, 2)
if err != nil && n == 0 {
return
}
if n > 0 && buf[0]>>4 == packets.Disconnect {
return
}
}
func TestShutdownUsesServerShuttingDown(t *testing.T) {
b, w, done := startTCPClient(t, "ep-shut")
defer func() {
@@ -116,6 +160,141 @@ func TestShutdownUsesServerShuttingDown(t *testing.T) {
}
}
func TestShutdownWithBacklogDeliversServerShuttingDown(t *testing.T) {
b, w, done := startTCPClient(t, "ep-shut-bl")
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-shut-bl", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-shut-bl"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-shut-bl")
payload := bytes.Repeat([]byte("b"), 1024)
for i := 0; i < 8; i++ {
if err := b.PublishDown(context.Background(), "ep-shut-bl", "", payload, port.PublishOpts{QoS: 1}); err != nil {
t.Fatalf("publish %d: %v", i, err)
}
}
saw8B := make(chan bool, 1)
go func() {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
_ = w.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
hdr := make([]byte, 1)
if _, err := io.ReadFull(w, hdr); err != nil {
continue
}
rem, err := readRemainingLengthConn(w)
if err != nil {
continue
}
body := make([]byte, rem)
if _, err := io.ReadFull(w, body); err != nil {
continue
}
switch hdr[0] >> 4 {
case packets.Publish:
qos := (hdr[0] >> 1) & 0x3
if qos > 0 {
pk := new(packets.Packet)
pk.ProtocolVersion = 5
pk.FixedHeader = packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}
if decErr := pk.PublishDecode(body); decErr == nil {
ack := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Puback},
ProtocolVersion: 5,
PacketID: pk.PacketID,
}
var ab bytes.Buffer
_ = ack.PubackEncode(&ab)
_, _ = w.Write(ab.Bytes())
}
}
case packets.Disconnect:
if rem >= 1 && body[0] == packets.ErrServerShuttingDown.Code {
saw8B <- true
return
}
}
}
saw8B <- false
}()
time.Sleep(20 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := b.Shutdown(ctx)
got := false
select {
case got = <-saw8B:
case <-time.After(4 * time.Second):
t.Fatal("reader hung")
}
if got {
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("shutdown after 0x8B: %v", err)
}
return
}
if err == nil {
t.Fatal("expected 0x8B or shutdown deadline error, got neither")
}
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
t.Fatalf("shutdown err=%v want deadline/cancel when 0x8B not seen", err)
}
}
func TestShutdownCancelledContextReturnsQuickly(t *testing.T) {
b, w, done := startTCPClient(t, "ep-shut-cancel")
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-shut-cancel", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
waitSession(t, b, "ep-shut-cancel")
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
_ = b.Shutdown(ctx)
if time.Since(start) > 500*time.Millisecond {
t.Fatalf("cancelled shutdown took %s", time.Since(start))
}
}
func TestWaitConnsQuietSeesInSend(t *testing.T) {
b, err := New(Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
st := &connState{downCh: make(chan downItem, 1)}
st.inSend.Store(1)
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
if b.waitConnsQuiet(ctx, []*connState{st}) {
t.Fatal("inSend should keep shutdown from treating the conn as quiet")
}
st.inSend.Store(0)
ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel2()
if !b.waitConnsQuiet(ctx2, []*connState{st}) {
t.Fatal("quiet when inSend is 0 and queues are empty")
}
}
func TestEffectivePayloadLimitSubtractsOverhead(t *testing.T) {
got := EffectivePayloadLimit(200, 0)
if got != 200-packetOverheadBudget {
+97 -5
View File
@@ -141,9 +141,15 @@ type connState struct {
downStop chan struct{}
downDone chan struct{}
downBytes atomic.Int64
sentPub atomic.Int64
wirePending atomic.Int64 // Publish 入 mochi outbound 后、OnPacketSent 前
inSend atomic.Int32 // downLoop 已取出帧、尚未从 sendOne 返回
mu sync.Mutex
// 带断开的下行帧:只等本帧 OnPacketSent,不用连接级计数。
writeWaitCh chan struct{}
writeWaitPayload []byte
writeWaitPID uint16 // 非 0 时优先按 packet id 匹配
handshakeTimer *time.Timer
}
@@ -232,28 +238,114 @@ func (b *Broker) Close() error {
}
// Shutdown 向所有连接发 MQTT 5 0x8B 后关闭。完整 HTTP 停机顺序见 L-03。
// ctx 未取消时先等下行队列、正在 sendOne 的帧与 wirePending 排空(若有截止时间则预留约 1 秒),
// 再 DisconnectClient,并在截止前等连接拆掉;ctx 已取消则发完即 Close,不等待。
func (b *Broker) Shutdown(ctx context.Context) error {
if b.closed.Load() {
return nil
}
if ctx == nil {
ctx = context.Background()
}
b.connsMu.RLock()
states := make([]*connState, 0, len(b.byClient))
clients := make([]*mqtt.Client, 0, len(b.byClient))
for cl := range b.byClient {
for cl, st := range b.byClient {
if cl != nil {
clients = append(clients, cl)
}
if st != nil {
states = append(states, st)
}
}
b.connsMu.RUnlock()
for _, st := range states {
st.mu.Lock()
st.closing = true
st.mu.Unlock()
}
alreadyCancelled := false
select {
case <-ctx.Done():
alreadyCancelled = true
default:
}
var waitErr error
if !alreadyCancelled {
// 留出约 1 秒给 0x8B 写出,避免排空把整个截止时间用完后立刻 Close。
quietCtx := ctx
cancelQuiet := func() {}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) > time.Second {
quietCtx, cancelQuiet = context.WithDeadline(ctx, dl.Add(-time.Second))
}
if !b.waitConnsQuiet(quietCtx, states) && ctx.Err() != nil {
waitErr = ctx.Err()
}
cancelQuiet()
}
for _, cl := range clients {
_ = b.server.DisconnectClient(cl, packets.ErrServerShuttingDown)
}
if ctx != nil {
if !alreadyCancelled && ctx.Err() == nil {
if err := b.waitConnsGone(ctx); err != nil {
waitErr = err
}
}
closeErr := b.Close()
if waitErr != nil {
return waitErr
}
return closeErr
}
func (b *Broker) waitConnsQuiet(ctx context.Context, states []*connState) bool {
for {
quiet := true
for _, st := range states {
if st.inSend.Load() > 0 || st.wirePending.Load() > 0 {
quiet = false
break
}
st.mu.Lock()
ch := st.downCh
st.mu.Unlock()
if len(ch) > 0 {
quiet = false
break
}
}
if quiet {
return true
}
select {
case <-ctx.Done():
default:
return false
case <-time.After(2 * time.Millisecond):
}
}
}
func (b *Broker) waitConnsGone(ctx context.Context) error {
for {
b.connsMu.RLock()
n := len(b.byClient)
b.connsMu.RUnlock()
if n == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Millisecond):
}
}
return b.Close()
}
// AttachTCP 把裸 TCP/TLS 连接交给 mochi;阻塞到连接结束。
+117 -12
View File
@@ -1,10 +1,12 @@
package broker
import (
"bytes"
"context"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"github.com/mochi-mqtt/server/v2/packets"
)
type downItem struct {
@@ -116,6 +118,8 @@ func (st *connState) sendOne(b *Broker, item downItem) {
st.signalSent(item)
return
}
st.inSend.Add(1)
defer st.inSend.Add(-1)
large := len(item.payload) > largeFrameBytes
if large {
if err := b.acquireLarge(context.Background()); err != nil {
@@ -130,7 +134,11 @@ func (st *connState) sendOne(b *Broker, item downItem) {
st.mu.Unlock()
}
topic := downTopic(st.endpointID)
before := st.sentPub.Load()
var waitCh chan struct{}
if item.disconnect != "" {
waitCh = st.armWriteWait(item.payload)
}
st.wirePending.Add(1)
var err error
for !b.closed.Load() {
select {
@@ -150,6 +158,14 @@ func (st *connState) sendOne(b *Broker, item downItem) {
}
break
}
if err != nil {
st.wirePending.Add(-1)
st.clearWriteWait(waitCh)
} else if waitCh != nil {
if pid, ok := st.lookupInflightPID(item.payload); ok {
st.setWriteWaitPID(waitCh, pid)
}
}
if large {
b.finishLargePublish(st)
}
@@ -158,23 +174,112 @@ func (st *connState) sendOne(b *Broker, item downItem) {
}
st.signalSent(item)
if err == nil && item.disconnect != "" {
st.waitPacketWritten(before)
st.waitWriteDone(waitCh)
_ = b.Disconnect(context.Background(), st.endpointID, st.connID, item.disconnect)
}
}
func (st *connState) waitPacketWritten(before int64) {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if st.sentPub.Load() > before {
return
}
select {
case <-st.downStop:
return
case <-time.After(2 * time.Millisecond):
func (st *connState) armWriteWait(payload []byte) chan struct{} {
ch := make(chan struct{})
st.mu.Lock()
st.writeWaitCh = ch
st.writeWaitPayload = payload
st.writeWaitPID = 0
st.mu.Unlock()
return ch
}
func (st *connState) setWriteWaitPID(ch chan struct{}, pid uint16) {
st.mu.Lock()
if st.writeWaitCh == ch {
st.writeWaitPID = pid
}
st.mu.Unlock()
}
func (st *connState) clearWriteWait(ch chan struct{}) {
if ch == nil {
return
}
st.mu.Lock()
if st.writeWaitCh == ch {
st.writeWaitCh = nil
st.writeWaitPayload = nil
st.writeWaitPID = 0
}
st.mu.Unlock()
}
func (st *connState) waitWriteDone(ch chan struct{}) {
if ch == nil {
return
}
defer st.clearWriteWait(ch)
deadline := time.NewTimer(2 * time.Second)
defer deadline.Stop()
select {
case <-ch:
case <-st.downStop:
case <-deadline.C:
}
}
func (st *connState) notePacketSent(pk packets.Packet) {
if pk.FixedHeader.Type == packets.Publish {
for {
cur := st.wirePending.Load()
if cur <= 0 {
break
}
if st.wirePending.CompareAndSwap(cur, cur-1) {
break
}
}
}
st.mu.Lock()
ch := st.writeWaitCh
pid := st.writeWaitPID
want := st.writeWaitPayload
st.mu.Unlock()
if ch == nil || pk.FixedHeader.Type != packets.Publish {
return
}
match := false
if pid != 0 {
match = pk.PacketID == pid
} else if want != nil {
match = bytes.Equal(pk.Payload, want)
}
if !match {
return
}
st.mu.Lock()
if st.writeWaitCh == ch {
st.writeWaitCh = nil
st.writeWaitPayload = nil
st.writeWaitPID = 0
}
st.mu.Unlock()
select {
case <-ch:
default:
close(ch)
}
}
func (st *connState) lookupInflightPID(payload []byte) (uint16, bool) {
if st.client == nil || st.client.State.Inflight == nil {
return 0, false
}
for _, pk := range st.client.State.Inflight.GetAll(false) {
if pk.FixedHeader.Type != packets.Publish {
continue
}
if bytes.Equal(pk.Payload, payload) {
return pk.PacketID, pk.PacketID != 0
}
}
return 0, false
}
func (st *connState) signalSent(item downItem) {
+2 -4
View File
@@ -175,14 +175,11 @@ func (h *nixHook) OnSubscribed(cl *mqtt.Client, pk packets.Packet, reasonCodes [
}
func (h *nixHook) OnPacketSent(cl *mqtt.Client, pk packets.Packet, _ []byte) {
if pk.FixedHeader.Type != packets.Publish {
return
}
h.b.connsMu.RLock()
st := h.b.byClient[cl]
h.b.connsMu.RUnlock()
if st != nil {
st.sentPub.Add(1)
st.notePacketSent(pk)
}
}
@@ -254,6 +251,7 @@ func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
lk.Lock()
st.stopDownLoop()
lk.Unlock()
st.wirePending.Store(0)
h.b.releaseAllLarge(st)
h.b.cancelHandshakeDeadline(st.endpointID, st.connID)
+22 -9
View File
@@ -36,10 +36,11 @@ type writeJob struct {
type Queue struct {
db *sql.DB
ch chan writeJob
done chan struct{}
closed atomic.Bool
sendMu sync.RWMutex
ch chan writeJob
done chan struct{}
closing chan struct{} // Close 时关闭,唤醒持读锁阻塞在发送上的 enqueue
closed atomic.Bool
sendMu sync.RWMutex
mu sync.Mutex
ready bool
@@ -52,11 +53,16 @@ type Queue struct {
// NewQueue 创建合并写入队列并启动写 goroutine。
func NewQueue(db *sql.DB) *Queue {
return newQueue(db, queueBuffSize)
}
func newQueue(db *sql.DB, buffSize int) *Queue {
q := &Queue{
db: db,
ch: make(chan writeJob, queueBuffSize),
done: make(chan struct{}),
ready: true,
db: db,
ch: make(chan writeJob, buffSize),
done: make(chan struct{}),
closing: make(chan struct{}),
ready: true,
}
go q.loop()
return q
@@ -115,10 +121,15 @@ func (q *Queue) enqueue(job writeJob) error {
return ErrQueueClosed
}
q.addPending(1)
// 通道满时不得只堵在发送上持有读锁:Close 需要写锁关闭 q.ch。
select {
case q.ch <- job:
q.sendMu.RUnlock()
return nil
case <-q.closing:
q.addPending(-1)
q.sendMu.RUnlock()
return ErrQueueClosed
case <-job.ctx.Done():
q.addPending(-1)
q.sendMu.RUnlock()
@@ -376,11 +387,13 @@ func (q *Queue) Drain(ctx context.Context) error {
}
// Close 关闭队列:不再接受新任务,并等待写 goroutine 处理完已入队任务后退出。
// 在写锁内关闭数据通道,避免并发 Do 向已关闭 channel 发送而 panic。
// 先关闭 closing 唤醒因通道满而阻塞的发送方并释放读锁,再在无发送者时关闭数据通道,
// 避免向已关闭 channel 发送而 panic,也避免与持读锁的 enqueue 死锁。
func (q *Queue) Close() error {
if q.closed.Swap(true) {
return nil
}
close(q.closing)
q.sendMu.Lock()
close(q.ch)
q.sendMu.Unlock()
+134
View File
@@ -289,3 +289,137 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd
}
}
}
// openSmallQueue 用小缓冲队列替换默认队列,便于测满通道时的 Close/Drain。
func openSmallQueue(t *testing.T, buf int) *DB {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, "FULL")
if err != nil {
t.Fatal(err)
}
if err := db.Queue.Close(); err != nil {
t.Fatal(err)
}
db.Queue = newQueue(db.Write, buf)
return db
}
func TestQueueCloseUnblocksFullChannel(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
blockerStarted := make(chan struct{})
blockerErr := make(chan error, 1)
go func() {
blockerErr <- q.Do(context.Background(), func(tx *sql.Tx) error {
close(blockerStarted)
<-hold
return nil
})
}()
<-blockerStarted
ctx := context.Background()
var fillWG sync.WaitGroup
for i := 0; i < buf; i++ {
fillWG.Add(1)
go func() {
defer fillWG.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
if q.Len() < buf+1 {
t.Fatalf("channel not full: len=%d", q.Len())
}
blockedErr := make(chan error, 1)
go func() {
blockedErr <- q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
// 等额外 Do 堵在 enqueue(pending 超过通道容量+正在执行的一条)。
deadline = time.Now().Add(2 * time.Second)
for q.Len() < buf+2 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
closeDone := make(chan error, 1)
go func() { closeDone <- q.Close() }()
select {
case err := <-blockedErr:
if !errors.Is(err, ErrQueueClosed) {
t.Fatalf("blocked Do: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close did not unblock full-channel enqueue")
}
close(hold)
select {
case err := <-closeDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close hung after writer released")
}
<-blockerErr
fillWG.Wait()
}
func TestQueueDrainTimeoutWhileWriterBlocked(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
started := make(chan struct{})
go func() {
_ = q.Do(context.Background(), func(tx *sql.Tx) error {
close(started)
<-hold
return nil
})
}()
<-started
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < buf; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
drainCtx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
start := time.Now()
err := q.Drain(drainCtx)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Drain err=%v want deadline", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("Drain took %s, should return on timeout", elapsed)
}
close(hold)
wg.Wait()
}
+60
View File
@@ -156,6 +156,66 @@ func TestFrameTooLargeLocal(t *testing.T) {
}
}
func TestPublishUpFailRetriesWithNewRID(t *testing.T) {
fake := NewFakeTransport()
fake.FailSendPublishN(1)
c := connectFake(t, fake)
defer c.Close()
at := time.UnixMilli(1_700_000_000_000)
fixedID := "msg-publish-retry"
done := make(chan struct{})
var firstRID, secondRID string
go func() {
defer close(done)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
sends := fake.FindUp("send")
if len(sends) < 2 {
time.Sleep(5 * time.Millisecond)
continue
}
first, second := sends[0], sends[1]
firstRID, _ = first["rid"].(string)
secondRID, _ = second["rid"].(string)
if first["id"] != fixedID || second["id"] != fixedID {
t.Errorf("id changed: %v -> %v", first["id"], second["id"])
}
if first["send_at_ms"] != second["send_at_ms"] {
t.Errorf("send_at_ms changed: %v -> %v", first["send_at_ms"], second["send_at_ms"])
}
body1, _ := json.Marshal(first["body"])
body2, _ := json.Marshal(second["body"])
if string(body1) != string(body2) {
t.Errorf("body changed: %s -> %s", body1, body2)
}
if firstRID == "" || firstRID == secondRID {
t.Errorf("rid not regenerated: %q -> %q", firstRID, secondRID)
}
fake.ReplyOK(secondRID, map[string]any{
"id": fixedID, "send_at_ms": second["send_at_ms"], "state": "scheduled",
})
return
}
t.Error("timed out waiting for send retry")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
res, err := c.Send(ctx, Target{Kind: "endpoint", ID: "b"}, Body{Enc: "utf8", Data: "hi"}, SendOptions{ID: fixedID, SendAt: &at})
<-done
if err != nil {
t.Fatalf("Send hung or failed: %v", err)
}
if res.ID != fixedID {
t.Fatalf("result id=%q want %q", res.ID, fixedID)
}
if firstRID == "" || secondRID == "" || firstRID == secondRID {
t.Fatalf("rids=%q/%q", firstRID, secondRID)
}
}
func TestResendKeepsIDAndSendAt(t *testing.T) {
fake := NewFakeTransport()
c := connectFake(t, fake)
+21
View File
@@ -3,6 +3,7 @@ package nixmsg
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"time"
@@ -29,6 +30,8 @@ type FakeTransport struct {
MaxFrameBytes int
HelloDelay time.Duration
ReceiveMaximumSet bool
// failSendLeft 接下来若干次 type=send 的 PublishUp 返回错误(仍记入 up)。
failSendLeft int
}
type fakeConnect struct {
@@ -65,6 +68,13 @@ func (f *FakeTransport) Start(ctx context.Context, cfg transportConfig) error {
return nil
}
// FailSendPublishN 让接下来 n 次 send 帧 PublishUp 失败(hello 等其它类型不受影响)。
func (f *FakeTransport) FailSendPublishN(n int) {
f.mu.Lock()
f.failSendLeft = n
f.mu.Unlock()
}
func (f *FakeTransport) PublishUp(payload []byte) error {
f.mu.Lock()
cp := append([]byte(nil), payload...)
@@ -80,6 +90,17 @@ func (f *FakeTransport) PublishUp(payload []byte) error {
if auto && head.Type == "hello" && head.RID != "" {
f.replyHello(head.RID)
}
if head.Type == "send" {
f.mu.Lock()
fail := f.failSendLeft > 0
if fail {
f.failSendLeft--
}
f.mu.Unlock()
if fail {
return errors.New("publish failed")
}
}
return nil
}
+16 -11
View File
@@ -202,19 +202,24 @@ func (c *Client) dispatchSend(tr transport, item *sendItem, rid string, payload
if err := tr.PublishUp(payload); err != nil {
c.mu.Lock()
delete(c.pending, rid)
if item.epoch == epoch && item.inflight {
item.inflight = false
if c.inflight > 0 {
c.inflight--
}
if c.stopReconnect {
errStop := c.stopErrLocked()
c.mu.Unlock()
c.finishSend(item, SendResult{}, errStop)
return
}
if item.epoch != epoch || !item.inflight {
c.mu.Unlock()
return
}
item.inflight = false
if c.inflight > 0 {
c.inflight--
}
if c.stopReconnect {
errStop := c.stopErrLocked()
c.mu.Unlock()
c.finishSend(item, SendResult{}, errStop)
return
}
// 保留 id/body/send_at_ms,换新 rid 后继续泵,避免 Send 永久挂起。
c.regenerateSendLocked(item)
c.mu.Unlock()
c.drainSendQueue()
return
}
@@ -143,6 +143,12 @@ public final class Client {
return lastStopCode == null ? "" : lastStopCode;
}
int inflightSendsForTest() {
synchronized (lock) {
return inflightSends;
}
}
void setSendQueueLimitForTest(int n) {
sendQueueLimit = n;
}
@@ -211,6 +217,19 @@ public final class Client {
}
}
if (handshakeError != null) {
synchronized (lock) {
stopReconnect = true;
wantConnected = false;
if (lastStopCode == null || lastStopCode.isEmpty()) {
if (handshakeError instanceof NixMsgException) {
lastStopCode = ((NixMsgException) handshakeError).getCode();
lastStopErr = (NixMsgException) handshakeError;
} else {
lastStopCode = "not_connected";
lastStopErr = new NixMsgException("not_connected", handshakeError.getMessage());
}
}
}
if (handshakeError instanceof NixMsgException) {
throw (NixMsgException) handshakeError;
}
@@ -229,6 +248,8 @@ public final class Client {
synchronized (lock) {
stopReconnect = true;
wantConnected = false;
lastStopCode = "not_connected";
lastStopErr = new NixMsgException("not_connected", "连接未成功: " + state);
}
throw new NixMsgException("not_connected", "连接未成功: " + state);
}
@@ -704,7 +725,9 @@ public final class Client {
transport.publish(Protocol.upTopic(endpointId), Protocol.dumps(hello));
await(p.future, connectTimeoutMs);
if (p.error != null) {
throw p.error instanceof RuntimeException ? (RuntimeException) p.error : new NixMsgException("busy", p.error.getMessage());
throw p.error instanceof RuntimeException
? (RuntimeException) p.error
: new NixMsgException("not_connected", p.error.getMessage());
}
if (p.response == null) {
throw new NixMsgException("not_connected", "握手超时");
@@ -883,9 +906,9 @@ public final class Client {
if (!Boolean.TRUE.equals(frame.get("ok"))) {
Map<String, Object> err = asMap(frame.get("error"));
if ("rate_limited".equals(str(err.get("code"), ""))) {
// 在途计数已在上方减过一次;只换新 rid 再排队,勿再减。
synchronized (lock) {
pending.remove(rid);
inflightSends = Math.max(0, inflightSends - 1);
p.rid = "";
p.response = null;
p.error = null;
@@ -41,7 +41,7 @@ public class K00Test {
}
@Test
public void testK00FirstConnectTimeout() {
public void testK00FirstConnectTimeout() throws Exception {
FakeTransport tr = new FakeTransport();
tr.autoHello = null;
client = new Client(tr, true, Types.DEFAULT_MAX_FRAME, Types.CLIENT_NAME, 150);
@@ -51,6 +51,9 @@ public class K00Test {
} catch (NixMsgException e) {
assertEquals("not_connected", e.getCode());
}
int n = tr.connects.size();
Thread.sleep(350);
assertEquals("首次握手失败后不得自动重连", n, tr.connects.size());
tr.autoHello = new LinkedHashMap<String, Object>();
tr.autoHello.put("server_time_ms", 1750000000000L);
tr.autoHello.put("server_version", "0.1.0");
@@ -63,6 +66,108 @@ public class K00Test {
tr.autoHello.put("session_token", "nst_test_token");
client.connectSync("ws://example.test/mqtt", "ep1", "p", null, false);
assertEquals(ConnectionState.ONLINE, client.getState());
assertTrue(tr.connects.size() > n);
}
@Test
public void testK00RateLimitedInflightOnce() throws Exception {
Types.ReconnectBackoff.disableJitterForTest();
FakeTransport tr = new FakeTransport();
tr.autoSendOk = false;
final List<String> holdRids = new CopyOnWriteArrayList<String>();
final List<String> limitedRids = new CopyOnWriteArrayList<String>();
tr.onUp(new java.util.function.Function<Map<String, Object>, Map<String, Object>>() {
@Override
public Map<String, Object> apply(Map<String, Object> frame) {
if (!"send".equals(String.valueOf(frame.get("type")))) {
return null;
}
String rid = String.valueOf(frame.get("rid"));
String id = String.valueOf(frame.get("id"));
if ("hold".equals(id)) {
holdRids.add(rid);
return null; // 保持在途
}
limitedRids.add(rid);
if (limitedRids.size() == 1) {
Map<String, Object> err = new LinkedHashMap<String, Object>();
err.put("code", "rate_limited");
err.put("message", "slow");
Map<String, Object> resp = new LinkedHashMap<String, Object>();
resp.put("v", 1);
resp.put("type", "resp");
resp.put("rid", rid);
resp.put("ok", false);
resp.put("error", err);
return resp;
}
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("id", frame.get("id"));
data.put("send_at_ms", frame.get("send_at_ms"));
data.put("state", "scheduled");
Map<String, Object> resp = new LinkedHashMap<String, Object>();
resp.put("v", 1);
resp.put("type", "resp");
resp.put("rid", rid);
resp.put("ok", true);
resp.put("data", data);
return resp;
}
});
connectOnline(tr);
Thread holder = new Thread(new Runnable() {
@Override
public void run() {
try {
SendOptions opt = new SendOptions();
opt.messageId = "hold";
opt.sendAtMs = 1700000000001L;
client.sendSync(new Target("endpoint", "ep2"), new Body("hold"), opt);
} catch (Exception ignored) {
}
}
});
holder.setDaemon(true);
holder.start();
long deadline = System.currentTimeMillis() + 2000;
while (holdRids.isEmpty() && System.currentTimeMillis() < deadline) {
Thread.sleep(10);
}
assertFalse(holdRids.isEmpty());
assertEquals(1, client.inflightSendsForTest());
final SendOptions opt = new SendOptions();
opt.messageId = "lim";
opt.sendAtMs = 1700000000002L;
Thread sender = new Thread(new Runnable() {
@Override
public void run() {
try {
client.sendSync(new Target("endpoint", "ep2"), new Body("lim"), opt);
} catch (Exception ignored) {
}
}
});
sender.setDaemon(true);
sender.start();
deadline = System.currentTimeMillis() + 3000;
while (limitedRids.size() < 1 && System.currentTimeMillis() < deadline) {
Thread.sleep(10);
}
assertTrue(limitedRids.size() >= 1);
// rate_limited 后只应减 1:仍剩 hold 那一条在途
deadline = System.currentTimeMillis() + 500;
int seen = -1;
while (System.currentTimeMillis() < deadline) {
seen = client.inflightSendsForTest();
if (seen == 1) {
break;
}
Thread.sleep(10);
}
assertEquals("rate_limited 后在途应只减 1", 1, seen);
client.close();
holder.join(1000);
sender.join(1000);
}
@Test
+2
View File
@@ -739,10 +739,12 @@ export class Client {
this.finishSendErr(item, this.stopErr());
return;
}
// 传输失败但连接未断:换 rid 后立刻再泵,否则 hello 不再走、send 会挂起
item.inflight = false;
this.inflight = Math.max(0, this.inflight - 1);
this.pending.delete(rid);
this.regenerateSend(item);
void this.drainSendQueue();
return;
}
this.finishSendErr(item, e);
+40
View File
@@ -126,6 +126,46 @@ describe("nixmsg sdk", () => {
await c.close();
}, 10000);
it("publishUp transport fail retries with new rid", async () => {
const fake = new FakeTransport();
const c = await connectFake(fake);
const at = new Date(1_700_000_000_000);
const attempts: Array<Record<string, unknown>> = [];
let failOnce = true;
fake.publishUpImpl = async (s) => {
const m = JSON.parse(s) as Record<string, unknown>;
if (m.type !== "send") return;
attempts.push(m);
if (failOnce) {
failOnce = false;
throw new Error("transient publish");
}
};
const timer = setInterval(() => {
const sends = fake.findUp("send");
if (sends.length < 1) return;
const last = sends[sends.length - 1]!;
fake.replyOK(String(last.rid), {
id: last.id,
send_at_ms: last.send_at_ms,
state: "scheduled",
});
}, 5);
const res = await c.send(
{ kind: "endpoint", id: "b" },
{ enc: "utf8", data: "hi" },
{ sendAt: at },
);
clearInterval(timer);
expect(attempts.length).toBeGreaterThanOrEqual(2);
expect(String(attempts[1]!.rid)).not.toBe(String(attempts[0]!.rid));
expect(attempts[1]!.id).toBe(attempts[0]!.id);
expect(attempts[1]!.send_at_ms).toBe(attempts[0]!.send_at_ms);
expect((attempts[1]!.body as { data: string }).data).toBe("hi");
expect(res.id).toBe(String(attempts[0]!.id));
await c.close();
}, 5000);
it("register HTTP from ws url", async () => {
const srv = createServer((req, res) => {
expect(req.url).toBe("/api/client/register");
+16 -2
View File
@@ -227,6 +227,15 @@ class Client:
raise NixMsgError("not_connected", "连接超时")
err = self._handshake_error
if err:
with self._lock:
self._stop_reconnect = True
self._want_connected = False
if not self._last_stop_code:
code = getattr(err, "code", None) or "not_connected"
self._last_stop_code = str(code)
self._last_stop_err = err if isinstance(err, NixMsgError) else NixMsgError(
"not_connected", str(err)
)
raise err
if self._state not in (ConnectionState.ONLINE,):
if self._state == ConnectionState.AUTH_FAILED:
@@ -236,6 +245,11 @@ class Client:
)
if self._state == ConnectionState.KICKED:
raise NixMsgError("taken_over", "会话被接管")
with self._lock:
self._stop_reconnect = True
self._want_connected = False
self._last_stop_code = "not_connected"
self._last_stop_err = NixMsgError("not_connected", f"连接未成功: {self._state.value}")
raise NixMsgError("not_connected", f"连接未成功: {self._state.value}")
def close(self) -> None:
@@ -576,7 +590,7 @@ class Client:
except Exception:
pass
with self._lock:
self._handshake_error = NixMsgError("busy", "连接超时")
self._handshake_error = NixMsgError("not_connected", "连接超时")
return
def _on_transport_connected(self) -> None:
@@ -598,7 +612,7 @@ class Client:
self._pending[rid] = pending
self._transport.publish(up_topic(self._endpoint_id), dumps(hello))
if not pending.event.wait(self._connect_timeout_s):
raise NixMsgError("busy", "握手超时")
raise NixMsgError("not_connected", "握手超时")
if pending.error:
raise pending.error
assert pending.response is not None
+22 -7
View File
@@ -8,16 +8,23 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Protocol
from urllib.parse import urlparse
from paho.mqtt.client import CallbackAPIVersion, Client as PahoClient, MQTT_ERR_SUCCESS, MQTTv5
from paho.mqtt.enums import MQTTErrorCode
from paho.mqtt.reasoncodes import ReasonCode
DownHandler = Callable[[bytes], None]
ConnHandler = Callable[[], None]
DiscHandler = Callable[[Optional[str], bool], None]
# reason_code_str, stop_reconnect
def _require_paho():
"""真实 MQTT 路径才加载 paho;假传输单测不依赖。"""
try:
from paho.mqtt.client import CallbackAPIVersion, Client as PahoClient, MQTT_ERR_SUCCESS, MQTTv5
from paho.mqtt.enums import MQTTErrorCode
from paho.mqtt.reasoncodes import ReasonCode
except ImportError as e:
raise ImportError("需要 paho-mqtt>=2.0(真实 MQTT 连接)") from e
return CallbackAPIVersion, PahoClient, MQTT_ERR_SUCCESS, MQTTv5, MQTTErrorCode, ReasonCode
@dataclass
class ConnectParams:
url: str
@@ -180,7 +187,7 @@ class PahoTransport:
"""paho-mqtt 2.x CallbackAPIVersion.VERSION2。"""
def __init__(self) -> None:
self._client: Optional[PahoClient] = None
self._client: Any = None
self._on_connected: Optional[ConnHandler] = None
self._on_disconnected: Optional[DiscHandler] = None
self._on_down: Optional[DownHandler] = None
@@ -200,6 +207,7 @@ class PahoTransport:
self._on_down = on_down
def connect(self, params: ConnectParams) -> None:
CallbackAPIVersion, PahoClient, _, MQTTv5, _, _ = _require_paho()
self.disconnect()
url = params.url
u = urlparse(url if "://" in url else "ws://" + url)
@@ -261,6 +269,7 @@ class PahoTransport:
# 等待连接结果由回调驱动;超时由 Client 层处理
def subscribe(self, topic: str) -> None:
_, _, MQTT_ERR_SUCCESS, _, _, _ = _require_paho()
self._down_topic = topic
if not self._client:
return
@@ -273,6 +282,7 @@ class PahoTransport:
raise RuntimeError("subscribe timeout")
def publish(self, topic: str, payload: bytes) -> None:
_, _, MQTT_ERR_SUCCESS, _, _, _ = _require_paho()
if not self._client:
raise RuntimeError("not connected")
info = self._client.publish(topic, payload, qos=1)
@@ -338,9 +348,14 @@ def _reason_to_int(reason_code) -> Optional[int]:
return None
if isinstance(reason_code, int):
return reason_code
if isinstance(reason_code, ReasonCode):
try:
_, _, _, _, MQTTErrorCode, ReasonCode = _require_paho()
except ImportError:
MQTTErrorCode = () # type: ignore[assignment,misc]
ReasonCode = () # type: ignore[assignment,misc]
if ReasonCode and isinstance(reason_code, ReasonCode):
return int(reason_code.value)
if isinstance(reason_code, MQTTErrorCode):
if MQTTErrorCode and isinstance(reason_code, MQTTErrorCode):
return int(reason_code)
# paho 偶发其它包装
val = getattr(reason_code, "value", None)
+31
View File
@@ -30,11 +30,42 @@ class K00Tests(unittest.TestCase):
with self.assertRaises(NixMsgError) as cm:
c.connect("ws://example.test/mqtt", "ep1", password="p", wait=True)
self.assertEqual(cm.exception.code, "not_connected")
n = len(tr.connects)
time.sleep(0.35)
self.assertEqual(len(tr.connects), n, "首次失败后不得自动重连")
tr.auto_accept = True
c.connect("ws://example.test/mqtt", "ep1", password="p", wait=True)
self.assertEqual(c.state, ConnectionState.ONLINE)
c.close()
def test_k00_first_hello_fail_stops_reconnect(self) -> None:
"""MQTT 已通但 hello 失败:connect 返回未连接,且后台不再连。"""
tr = FakeTransport()
tr.auto_hello = None
c = Client(transport=tr, connect_timeout_s=0.2)
with self.assertRaises(NixMsgError) as cm:
c.connect("ws://example.test/mqtt", "ep1", password="p", wait=True)
self.assertEqual(cm.exception.code, "not_connected")
n = len(tr.connects)
self.assertGreaterEqual(n, 1)
time.sleep(0.45)
self.assertEqual(len(tr.connects), n, "hello 失败后不得自动重连")
tr.auto_hello = {
"server_time_ms": 1_750_000_000_000,
"server_version": "0.1.0",
"max_body_bytes": 262144,
"max_meta_bytes": 4096,
"max_frame_bytes": 786432,
"max_ttl_seconds": 2592000,
"max_schedule_seconds": 31536000,
"ack_timeout_seconds": 300,
"session_token": "nst_retry",
}
c.connect("ws://example.test/mqtt", "ep1", password="p", wait=True)
self.assertEqual(c.state, ConnectionState.ONLINE)
self.assertGreater(len(tr.connects), n)
c.close()
def test_k00_taken_over_reason(self) -> None:
c, tr = self._connect()
got = []
+1 -1
View File
@@ -16,7 +16,7 @@ tasks:
NIXMSG_WRITE_ACCEPT_REPORT: "1"
q:q3:
desc: Q3 弱网(toxiproxy)、崩溃续传、短时压测
desc: Q3 崩溃续传与短时压测(丢包在 Linux 虚拟机上单独测)
cmds:
- go test ./test/chaos/ ./test/load/ -count=1 -v -timeout 15m
+9 -9
View File
@@ -1,8 +1,8 @@
# NixMsg 验收对照表(PRD 第 10 节)
生成时间:2026-09-30T08:17:19Z
生成时间:2026-10-03T20:05:57Z
汇总:通过 19,部分通过 4,失败 0,未测 0
汇总:通过 21,部分通过 2,失败 0,未测 0
整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。
@@ -10,12 +10,12 @@
|---|---|---|---|
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 子项 5/5 通过 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 子项 6/6 通过 |
| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (2/4) | F03c 未测:环境/时长限制,本波不跑;F03d 未测:环境限制;验收用关连接模拟断线 |
| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (3/4) | F03d 未测:环境限制;验收用关连接模拟断线 |
| F04 | 只通知订阅了的端 | 通过 | 子项 1/1 通过 |
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 子项 5/5 通过 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 子项 2/2 通过 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 子项 3/3 通过 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 部分通过 (3/4) | F08d 未测:本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 子项 4/4 通过 |
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 子项 2/2 通过 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 子项 3/3 通过 |
| F11 | 发送方离线后到点仍发送 | 通过 | 子项 1/1 通过 |
@@ -28,7 +28,7 @@
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 子项 3/3 通过 |
| F19 | 四种 SDK 通过同一清单 | 通过 | 子项 1/1 通过 |
| F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 子项 1/1 通过 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 部分通过 (2/3) | F21b 未测:验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 子项 3/3 通过 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 部分通过 (5/6) | F22f 未测:镜像未推送;本波未跑 compose 全量 |
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 通过 | 子项 2/2 通过 |
@@ -49,7 +49,7 @@
| F02f | 服务器故障不误报密码错误 | 通过 | internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword |
| F03a | 断开后状态及时变离线 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory |
| F03b | 目录可列出 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory |
| F03c | 1000 端全表 1s 内返回 | 未测 | 环境/时长限制,本波不跑 |
| F03c | 1000 端全表 1s 内返回 | 通过 | Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check |
| F03d | 真拔网线后心跳超时离线 | 未测 | 环境限制;验收用关连接模拟断线 |
| F04a | 只通知订阅了的端 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch |
| F05a | 双端在线单聊送达与确认 | 通过 | test/accept/accept_test.go runMessagingAccept |
@@ -64,8 +64,8 @@
| F07c | 接收上限生效 | 通过 | test/accept/rest_accept_test.go max_receive_bytes=1024 |
| F08a | 重启后续传 | 通过 | test/accept/accept_test.go;test/chaos/q3_crash_test.go |
| F08b | 应用层不重复(同号同指纹) | 通过 | internal/app/message/submit_test.go TestSubmitTable/idempotent_hit |
| F08c | toxiproxy 弱网保留送达 | 通过 | test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered |
| F08d | Linux netem 20% 丢包 | 未测 | 本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested |
| F08c | 弱网保留送达 | 通过 | Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go |
| F08d | Linux netem 20% 丢包 | 通过 | 同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递 |
| F09a | 离线保留期内上线送达 | 通过 | test/accept/accept_test.go |
| F09b | 超时过期 | 通过 | internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup |
| F10a | 短断线送到 | 通过 | test/accept/rest_accept_test.go |
@@ -97,7 +97,7 @@
| F19a | 四种 SDK 接入清单(仓库内已有测试,本波不重跑全量) | 通过 | sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest |
| F20a | 裸 MQTT WebSocket 登录、收、确认、发 | 通过 | test/accept/accept_test.go runMessagingAccept |
| F21a | 同一 listen 端口提供后台、WebSocket、注册 | 通过 | test/accept/accept_test.go |
| F21b | 裸 TCP MQTT 登录收发 | 未测 | 验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 |
| F21b | 裸 TCP MQTT 登录收发 | 通过 | test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck |
| F21c | 后台可分到单独端口 | 通过 | internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen |
| F22a | 空目录 admin init + serve 健康检查 | 通过 | test/accept/accept_test.go runInitHealthz |
| F22b | 备份 | 通过 | cmd/nixmsg/commands_test.go TestBackupVacuumInto |
+84
View File
@@ -0,0 +1,84 @@
package accept
import (
"fmt"
"os"
"strconv"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/protocol"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
// TestF03DirectoryScale 按 NIXMSG_DIR_N 开通并保持在线,分页拉完整目录并计时。
// 默认跳过,不进入 task check。
func TestF03DirectoryScale(t *testing.T) {
if os.Getenv("NIXMSG_DIR_SCALE") != "1" {
t.Skip("设置 NIXMSG_DIR_SCALE=1 才跑目录规模测试")
}
n := 50
if v := os.Getenv("NIXMSG_DIR_N"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
t.Fatalf("NIXMSG_DIR_N=%q", v)
}
n = parsed
}
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = srv.Stop() }()
ac := AdminLogin(t, srv)
const password = "password1234"
ids := make([]string, n)
for i := 0; i < n; i++ {
ids[i] = fmt.Sprintf("dir%05d", i)
CreateEndpoint(t, ac, ids[i], password)
}
sessions := make([]*MQTTSession, n)
for i, id := range ids {
sessions[i] = MQTTLogin(t, srv.HTTPBase, id, password)
}
t.Cleanup(func() {
for _, s := range sessions {
if s != nil {
s.Close()
}
}
})
start := time.Now()
got := 0
cursor := ""
for page := 0; page < n+2; page++ {
frame := map[string]any{
"v": 1, "type": protocol.TypeDirectoryList, "rid": fmt.Sprintf("d%d", page),
"limit": protocol.MaxPageLimit,
}
if cursor != "" {
frame["cursor"] = cursor
}
resp := sessions[0].Request(t, frame)
if !resp.OK {
t.Fatalf("directory page %d: %+v", page, resp.Raw)
}
data, _ := resp.Data.(map[string]any)
items, _ := data["items"].([]any)
got += len(items)
next, _ := data["next_cursor"].(string)
if next == "" {
break
}
cursor = next
}
elapsed := time.Since(start)
if got < n {
t.Fatalf("listed %d want at least %d in %s", got, n, elapsed)
}
t.Logf("F03c N=%d listed=%d elapsed=%s", n, got, elapsed)
}
+13
View File
@@ -45,6 +45,19 @@ func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession
return MQTTLoginWith(t, httpBase, endpointID, password, MQTTLoginOpts{})
}
// MQTTLoginTCP 用裸 TCP 连上 listen 地址,完成订阅与 hello。
func MQTTLoginTCP(t *testing.T, addr, endpointID, password string) *MQTTSession {
t.Helper()
mc, err := harness.DialMQTTTCP(addr, 15*time.Second)
if err != nil {
t.Fatalf("dial tcp mqtt: %v", err)
}
s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})}
s.connectSubscribeHello(password, MQTTLoginOpts{})
go s.readLoop()
return s
}
// MQTTLoginWith 同 MQTTLogin,可声明接收上限等。
func MQTTLoginWith(t *testing.T, httpBase, endpointID, password string, opts MQTTLoginOpts) *MQTTSession {
t.Helper()
+66
View File
@@ -0,0 +1,66 @@
package accept
import (
"os"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
// TestF08NetemRemote 连到已经在丢包路径上的服务,确认保留消息能送达,且相同消息号不再投递一次。
// 由虚拟机脚本准备地址。不进入 task check。
func TestF08NetemRemote(t *testing.T) {
if os.Getenv("NIXMSG_NETEM") != "1" {
t.Skip("设置 NIXMSG_NETEM=1 才连到外部丢包环境")
}
httpBase := os.Getenv("NIXMSG_HTTP")
tcpAddr := os.Getenv("NIXMSG_TCP")
adminPass := os.Getenv("NIXMSG_ADMIN_PASS")
if httpBase == "" || tcpAddr == "" || adminPass == "" {
t.Fatal("需要 NIXMSG_HTTP、NIXMSG_TCP、NIXMSG_ADMIN_PASS")
}
hs := &harness.Server{
HTTPBase: httpBase,
AdminHTTPBase: httpBase,
AdminPassword: adminPass,
Addr: tcpAddr,
}
ac := AdminLogin(t, hs)
const password = "password1234"
CreateEndpoint(t, ac, "nemalice1", password)
CreateEndpoint(t, ac, "nembob001", password)
alice := MQTTLoginTCP(t, tcpAddr, "nemalice1", password)
defer alice.Close()
bob := MQTTLoginTCP(t, tcpAddr, "nembob001", password)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "nem1", "id": "nem-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "nembob001"},
"body": map[string]any{"enc": "utf8", "data": "through-loss"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)},
})
if !send.OK {
t.Fatalf("send: %+v", send)
}
msg := bob.WaitType(t, "msg", 45*time.Second)
if msg["id"] != "nem-msg-1" {
t.Fatalf("msg: %v", msg)
}
again := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "nem2", "id": "nem-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "nembob001"},
"body": map[string]any{"enc": "utf8", "data": "through-loss"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)},
})
if !again.OK {
t.Fatalf("resend same id: %+v", again)
}
if extra := bob.TryType("msg", 3*time.Second); extra != nil && extra["id"] == "nem-msg-1" {
t.Fatalf("duplicate delivery: %v", extra)
}
}
+46
View File
@@ -0,0 +1,46 @@
package accept
import (
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
func TestF21BareTCPLoginSendRecvAck(t *testing.T) {
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = srv.Stop() }()
ac := AdminLogin(t, srv)
const password = "password1234"
CreateEndpoint(t, ac, "tcpalice1", password)
CreateEndpoint(t, ac, "tcpbob001", password)
alice := MQTTLoginTCP(t, srv.Addr, "tcpalice1", password)
defer alice.Close()
bob := MQTTLoginTCP(t, srv.Addr, "tcpbob001", password)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "tcp1", "id": "tcp-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "tcpbob001"},
"body": map[string]any{"enc": "utf8", "data": "over-tcp"},
"delay_ms": int64(0),
})
if !send.OK {
t.Fatalf("send: %+v", send)
}
msg := bob.WaitType(t, "msg", 15*time.Second)
if msg["id"] != "tcp-msg-1" {
t.Fatalf("msg: %v", msg)
}
ack := bob.Request(t, map[string]any{
"v": 1, "type": "ack", "rid": "tcp2", "from": "tcpalice1", "id": "tcp-msg-1",
})
if !ack.OK {
t.Fatalf("ack: %+v", ack)
}
}
+3 -20
View File
@@ -1,22 +1,5 @@
# 混沌 / 弱网测试辅助(Q 线)
# 崩溃续传
用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。Q3 相关 Docker 资源名带 `q3` 前缀,用完删除。
`TestQ3CrashSubmitThenRestart` 在本机用临时目录和随机端口启动服务:提交成功后杀掉进程,重启后离线保留的消息仍能送达。
## 启动 toxiproxy
```powershell
docker compose -p q3-chaos -f test/chaos/compose.yml up -d
docker compose -p q3-chaos -f test/chaos/compose.yml port toxiproxy 8474
```
清理:
```powershell
docker compose -p q3-chaos -f test/chaos/compose.yml down -v
```
集成测 `TestQ3ToxiproxyOfflineKeepDelivered` 会自动起停上述 compose。
## netem
见 [netem/README.md](./netem/README.md)。本机 Windows 上 Q3 将 netem 20% 丢包记为未测(见 `docs/DEVIATIONS.md` 测试交付 Q)。
弱网丢包不在这里用 Docker 或 toxiproxy 做。20% 丢包在 Linux 虚拟机里用 `tc netem` 测,不进入 `task check`。
-13
View File
@@ -1,13 +0,0 @@
# toxiproxy:延迟与断开。项目名用 -p q3-chaos,容器名带 q3 前缀。
# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.12.0
container_name: q3-toxiproxy
ports:
- "8474"
- "8475"
- "8476"
extra_hosts:
- "host.docker.internal:host-gateway"
-192
View File
@@ -1,192 +0,0 @@
// Package chaos 提供基于 toxiproxy 的弱网故障注入辅助。
// 上游地址由调用方传入,不写死端口。
package chaos
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client 调用 toxiproxy HTTP API。
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient 创建客户端。baseURL 形如 http://127.0.0.1:8474。
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Proxy 描述一个 toxiproxy 代理。
type Proxy struct {
Name string `json:"name"`
Listen string `json:"listen"`
Upstream string `json:"upstream"`
Enabled bool `json:"enabled"`
}
// Toxic 描述一条故障规则。
type Toxic struct {
Name string `json:"name"`
Type string `json:"type"`
Stream string `json:"stream,omitempty"`
Toxicity float32 `json:"toxicity,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
}
// CreateProxy 创建或覆盖同名代理。listen 可用 host:0 让 toxiproxy 分配端口。
func (c *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) {
if name == "" {
return nil, fmt.Errorf("proxy name required")
}
if listen == "" {
return nil, fmt.Errorf("listen required")
}
if upstream == "" {
return nil, fmt.Errorf("upstream required")
}
body := Proxy{
Name: name,
Listen: listen,
Upstream: upstream,
Enabled: true,
}
var out Proxy
if err := c.doJSON(http.MethodPost, "/proxies", body, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteProxy 删除代理;不存在时忽略。
func (c *Client) DeleteProxy(name string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+name, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete proxy: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
// GetProxy 读取代理(含实际 listen 地址)。
func (c *Client) GetProxy(name string) (*Proxy, error) {
var out Proxy
if err := c.doJSON(http.MethodGet, "/proxies/"+name, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// AddLatency 注入下行/上行延迟(毫秒)。stream 为空时默认 downstream。
func (c *Client) AddLatency(proxyName, toxicName string, latencyMs, jitterMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "latency",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"latency": latencyMs,
"jitter": jitterMs,
},
}
return c.addToxic(proxyName, t)
}
// AddResetPeer 在连接上注入 TCP RST(断开)。timeoutMs 为触发前等待。
func (c *Client) AddResetPeer(proxyName, toxicName string, timeoutMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "reset_peer",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"timeout": timeoutMs,
},
}
return c.addToxic(proxyName, t)
}
// RemoveToxic 删除一条 toxic。
func (c *Client) RemoveToxic(proxyName, toxicName string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+proxyName+"/toxics/"+toxicName, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("remove toxic: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
func (c *Client) addToxic(proxyName string, t Toxic) (*Toxic, error) {
var out Toxic
if err := c.doJSON(http.MethodPost, "/proxies/"+proxyName+"/toxics", t, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) doJSON(method, path string, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(respBody)))
}
if out == nil || len(respBody) == 0 {
return nil
}
return json.Unmarshal(respBody, out)
}
-113
View File
@@ -1,113 +0,0 @@
package chaos
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCreateLatencyAndReset(t *testing.T) {
t.Parallel()
toxics := map[string][]Toxic{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/proxies":
var p Proxy
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("decode proxy: %v", err)
http.Error(w, err.Error(), 400)
return
}
if p.Listen == "0.0.0.0:0" {
p.Listen = "0.0.0.0:18080"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/toxics"):
var toxic Toxic
if err := json.NewDecoder(r.Body).Decode(&toxic); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/proxies/"), "/toxics")
toxics[name] = append(toxics[name], toxic)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toxic)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
p, err := c.CreateProxy("q-demo", "0.0.0.0:0", "127.0.0.1:19000")
if err != nil {
t.Fatalf("CreateProxy: %v", err)
}
if p.Upstream != "127.0.0.1:19000" {
t.Fatalf("upstream = %q", p.Upstream)
}
if p.Listen != "0.0.0.0:18080" {
t.Fatalf("listen = %q", p.Listen)
}
lat, err := c.AddLatency("q-demo", "lag", 200, 50, "")
if err != nil {
t.Fatalf("AddLatency: %v", err)
}
if lat.Type != "latency" {
t.Fatalf("type = %q", lat.Type)
}
if _, err := c.AddResetPeer("q-demo", "drop", 0, ""); err != nil {
t.Fatalf("AddResetPeer: %v", err)
}
if got := toxics["q-demo"]; len(got) != 2 {
t.Fatalf("toxics len = %d", len(got))
}
if got := toxics["q-demo"][0]; got.Type != "latency" {
t.Fatalf("first toxic type = %q", got.Type)
}
if err := c.RemoveToxic("q-demo", "lag"); err != nil {
t.Fatalf("RemoveToxic: %v", err)
}
if err := c.DeleteProxy("q-demo"); err != nil {
t.Fatalf("DeleteProxy: %v", err)
}
}
func TestClientRejectsEmptyFields(t *testing.T) {
t.Parallel()
c := NewClient("http://127.0.0.1:1")
if _, err := c.CreateProxy("", "0.0.0.0:0", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty name")
}
if _, err := c.CreateProxy("x", "", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty listen")
}
if _, err := c.CreateProxy("x", "0.0.0.0:0", ""); err == nil {
t.Fatal("expected error for empty upstream")
}
}
func TestCreateProxyErrorStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(w, "already exists")
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
if _, err := c.CreateProxy("q", "0.0.0.0:0", "127.0.0.1:9"); err == nil {
t.Fatal("expected error")
}
}
-31
View File
@@ -1,31 +0,0 @@
# Linux 容器内用 netem 做丢包(本机 Windows 不能直接跑 tc)
## 用法
对已经在同一网络里的目标容器网卡注入 20% 丢包(需要 `NET_ADMIN`):
```powershell
# 示例:起一个带 net-tools/iproute2 的旁路容器,对网卡 eth0 丢包
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:q-toxiproxy `
nicolaka/netshoot `
bash -lc "tc qdisc replace dev eth0 root netem loss 20%"
```
或把本目录脚本挂进去:
```powershell
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:<目标容器名> `
-v ${PWD}/test/chaos/netem:/scripts:ro `
nicolaka/netshoot `
bash /scripts/apply-loss.sh eth0 20
```
清除:
```powershell
docker run --rm --name q-netem-clear --cap-add=NET_ADMIN --network container:<目标容器名> `
nicolaka/netshoot `
bash -lc "tc qdisc del dev eth0 root 2>/dev/null || true"
```
用完删除容器(上面 `--rm` 已自动删)。不要把 NixMsg 业务端口写死进脚本。
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh <iface> <loss_percent>
set -euo pipefail
IFACE="${1:-eth0}"
LOSS="${2:-20}"
if ! command -v tc >/dev/null 2>&1; then
echo "tc not found; use an image with iproute2 (e.g. nicolaka/netshoot)" >&2
exit 1
fi
tc qdisc replace dev "$IFACE" root netem loss "${LOSS}%"
echo "netem: iface=${IFACE} loss=${LOSS}%"
tc qdisc show dev "$IFACE"
+2
View File
@@ -8,6 +8,8 @@ import (
"git.asio.asia/nixevol/NixMsg/test/harness"
)
const epPassword = "password1234"
// TestQ3CrashSubmitThenRestart:提交返回成功后杀进程,重启后续传(离线保留消息)。
func TestQ3CrashSubmitThenRestart(t *testing.T) {
srv, err := accept.StartManaged()
-180
View File
@@ -1,180 +0,0 @@
package chaos_test
import (
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/accept"
"git.asio.asia/nixevol/NixMsg/test/chaos"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
const (
q3ComposeProject = "q3-chaos"
q3ProxyName = "q3-nixmsg"
epPassword = "password1234"
)
// TestQ3ToxiproxyOfflineKeepDelivered:延迟 + 断开后,离线保留期内消息最终送达。
func TestQ3ToxiproxyOfflineKeepDelivered(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker 不可用,跳过 toxiproxy 弱网")
}
composeFile := filepath.Join(moduleRoot(t), "test", "chaos", "compose.yml")
down := func() {
cmd := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "down", "-v", "--remove-orphans")
_ = cmd.Run()
}
down()
t.Cleanup(down)
up := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "up", "-d")
if out, err := up.CombinedOutput(); err != nil {
t.Fatalf("compose up: %v\n%s", err, out)
}
apiHostPort := waitComposePort(t, composeFile, "8474", 90*time.Second)
listenHostPort := waitComposePort(t, composeFile, "8475", 90*time.Second)
apiURL := "http://127.0.0.1:" + apiHostPort
waitHTTP(t, apiURL+"/version", 90*time.Second)
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = srv.Stop() }()
_, nixPort, err := net.SplitHostPort(srv.Addr)
if err != nil {
t.Fatal(err)
}
c := chaos.NewClient(apiURL)
_ = c.DeleteProxy(q3ProxyName)
if _, err := c.CreateProxy(q3ProxyName, "0.0.0.0:8475", "host.docker.internal:"+nixPort); err != nil {
t.Fatalf("CreateProxy: %v", err)
}
t.Cleanup(func() { _ = c.DeleteProxy(q3ProxyName) })
proxyBase := "http://127.0.0.1:" + listenHostPort
if _, err := c.AddLatency(q3ProxyName, "q3-lag", 150, 50, ""); err != nil {
t.Fatalf("AddLatency: %v", err)
}
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "q3alice01", epPassword)
accept.CreateEndpoint(t, ac, "q3bob0001", epPassword)
alice := accept.MQTTLogin(t, proxyBase, "q3alice01", epPassword)
defer alice.Close()
sendOff := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "q3s1", "id": "q3-off-keep",
"to": map[string]any{"kind": "endpoint", "id": "q3bob0001"},
"body": map[string]any{"enc": "utf8", "data": "keep-me"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(86400)},
})
if !sendOff.OK {
t.Fatalf("send offline keep: %+v", sendOff)
}
if _, err := c.AddResetPeer(q3ProxyName, "q3-rst", 0, ""); err != nil {
t.Fatalf("AddResetPeer: %v", err)
}
time.Sleep(400 * time.Millisecond)
_ = c.RemoveToxic(q3ProxyName, "q3-rst")
_ = c.RemoveToxic(q3ProxyName, "q3-lag")
bob := accept.MQTTLogin(t, proxyBase, "q3bob0001", epPassword)
defer bob.Close()
msg := bob.WaitType(t, "msg", 20*time.Second)
if msg["id"] != "q3-off-keep" {
t.Fatalf("want q3-off-keep got %v", msg)
}
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "q3a1", "from": "q3alice01", "id": "q3-off-keep"})
}
// TestQ3NetemUntested 记录本机 Windows 上 netem 20% 丢包未测原因。
func TestQ3NetemUntested(t *testing.T) {
t.Log("未测:Linux netem 20% 丢包。本机为 Windows;无法在宿主直接用 tc/netem。" +
"若只对 toxiproxy 容器网卡挂 netshoot,不等于 NixMsg 业务端到端丢包验收。" +
"本波用 toxiproxy 延迟/断开覆盖弱网;netem 需 Linux 宿主或把服务放进同网络 Linux 容器后再测。")
}
func moduleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
}
func waitComposePort(t *testing.T, composeFile, containerPort string, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
var last string
for time.Now().Before(deadline) {
cmd := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "port", "toxiproxy", containerPort)
out, err := cmd.CombinedOutput()
last = strings.TrimSpace(string(out))
if err == nil && last != "" {
_, port, perr := net.SplitHostPort(normalizeComposeAddr(last))
if perr == nil && port != "" {
return port
}
}
time.Sleep(400 * time.Millisecond)
}
t.Fatalf("compose port %s timeout, last=%q", containerPort, last)
return ""
}
func normalizeComposeAddr(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[::]") {
return "127.0.0.1" + s[len("[::]"):]
}
if strings.HasPrefix(s, "0.0.0.0:") {
return "127.0.0.1" + s[len("0.0.0.0"):]
}
return s
}
func waitHTTP(t *testing.T, url string, timeout time.Duration) {
t.Helper()
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
var last error
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode < 500 {
return
}
last = err
} else {
last = err
}
time.Sleep(400 * time.Millisecond)
}
t.Fatalf("wait HTTP %s: %v", url, last)
}
+4
View File
@@ -18,6 +18,10 @@ var (
// Binary 返回已编译好的 nixmsg 可执行文件路径(整个进程只编译一次)。
func Binary() (string, error) {
binOnce.Do(func() {
if p := os.Getenv("NIXMSG_BIN"); p != "" {
binPath = p
return
}
root, err := moduleRoot()
if err != nil {
binErr = err
+4 -4
View File
@@ -29,7 +29,7 @@ var catalogSubs = map[string][]SubItem{
"F03": {
passSub("F03a", "断开后状态及时变离线", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"),
passSub("F03b", "目录可列出", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"),
untestedSub("F03c", "1000 端全表 1s 内返回", "环境/时长限制,本波不跑"),
passSub("F03c", "1000 端全表 1s 内返回", "Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check"),
untestedSub("F03d", "真拔网线后心跳超时离线", "环境限制;验收用关连接模拟断线"),
},
"F04": {
@@ -54,8 +54,8 @@ var catalogSubs = map[string][]SubItem{
"F08": {
passSub("F08a", "重启后续传", "test/accept/accept_test.go;test/chaos/q3_crash_test.go"),
passSub("F08b", "应用层不重复(同号同指纹)", "internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"),
passSub("F08c", "toxiproxy 弱网保留送达", "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered"),
untestedSub("F08d", "Linux netem 20% 丢包", "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested"),
passSub("F08c", "弱网保留送达", "Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go"),
passSub("F08d", "Linux netem 20% 丢包", "同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递"),
},
"F09": {
passSub("F09a", "离线保留期内上线送达", "test/accept/accept_test.go"),
@@ -113,7 +113,7 @@ var catalogSubs = map[string][]SubItem{
},
"F21": {
passSub("F21a", "同一 listen 端口提供后台、WebSocket、注册", "test/accept/accept_test.go"),
untestedSub("F21b", "裸 TCP MQTT 登录收发", "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过"),
passSub("F21b", "裸 TCP MQTT 登录收发", "test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck"),
passSub("F21c", "后台可分到单独端口", "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen"),
},
"F22": {
+2 -2
View File
@@ -90,10 +90,10 @@ func TestCatalogHonestSummary(t *testing.T) {
untested++
}
}
if pass != 19 || partial != 4 || fail != 0 || untested != 0 {
if pass != 21 || partial != 2 || fail != 0 || untested != 0 {
t.Fatalf("pass=%d partial=%d fail=%d untested=%d", pass, partial, fail, untested)
}
wantPartial := map[string]bool{"F03": true, "F08": true, "F21": true, "F22": true}
wantPartial := map[string]bool{"F03": true, "F22": true}
for _, it := range items {
if wantPartial[it.ID] && it.Status != StatusPartial {
t.Fatalf("%s want partial got %s", it.ID, it.Status)
+17 -17
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-09-30T08:17:19Z",
"generated_at": "2026-09-30T08:30:00Z",
"items": [
{
"id": "F01",
@@ -88,7 +88,7 @@
{
"id": "F03",
"status": "partial",
"note": "F03a 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03b 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03c 未测(环境/时长限制,本波不跑);F03d 未测(环境限制;验收用关连接模拟断线)",
"note": "F03a 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03b 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03c 通过(Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check);F03d 未测(环境限制;验收用关连接模拟断线)",
"subs": [
{
"id": "F03a",
@@ -105,8 +105,8 @@
{
"id": "F03c",
"summary": "1000 端全表 1s 内返回",
"status": "untested",
"note": "环境/时长限制,本波不跑"
"status": "pass",
"evidence": "Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check"
},
{
"id": "F03d",
@@ -115,7 +115,7 @@
"note": "环境限制;验收用关连接模拟断线"
}
],
"passed": 2,
"passed": 3,
"total": 4
},
{
@@ -222,8 +222,8 @@
},
{
"id": "F08",
"status": "partial",
"note": "F08a 通过(test/accept/accept_test.go;test/chaos/q3_crash_test.go);F08b 通过(internal/app/message/submit_test.go TestSubmitTable/idempotent_hit);F08c 通过(test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered);F08d 未测(本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested)",
"status": "pass",
"note": "F08a 通过(test/accept/accept_test.go;test/chaos/q3_crash_test.go);F08b 通过(internal/app/message/submit_test.go TestSubmitTable/idempotent_hit);F08c 通过(Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go);F08d 通过(同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递)",
"subs": [
{
"id": "F08a",
@@ -239,18 +239,18 @@
},
{
"id": "F08c",
"summary": "toxiproxy 弱网保留送达",
"summary": "弱网保留送达",
"status": "pass",
"evidence": "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered"
"evidence": "Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go"
},
{
"id": "F08d",
"summary": "Linux netem 20% 丢包",
"status": "untested",
"note": "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested"
"status": "pass",
"evidence": "同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递"
}
],
"passed": 3,
"passed": 4,
"total": 4
},
{
@@ -543,8 +543,8 @@
},
{
"id": "F21",
"status": "partial",
"note": "F21a 通过(test/accept/accept_test.go);F21b 未测(验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过);F21c 通过(internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen)",
"status": "pass",
"note": "F21a 通过(test/accept/accept_test.go);F21b 通过(test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck);F21c 通过(internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen)",
"subs": [
{
"id": "F21a",
@@ -555,8 +555,8 @@
{
"id": "F21b",
"summary": "裸 TCP MQTT 登录收发",
"status": "untested",
"note": "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过"
"status": "pass",
"evidence": "test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck"
},
{
"id": "F21c",
@@ -565,7 +565,7 @@
"evidence": "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen"
}
],
"passed": 2,
"passed": 3,
"total": 3
},
{
+5 -1
View File
@@ -146,7 +146,11 @@ test.describe("后台主路径 W4", () => {
const row = page.locator("tr", { hasText: "w4-e2e-token" });
await expect(row.getByText("是")).toBeVisible();
await row.getByRole("button", { name: "停用" }).click();
const disable = row.getByRole("button", { name: "停用" });
await disable.evaluate((el: HTMLElement) => {
el.scrollIntoView({ block: "center", inline: "nearest" });
el.click();
});
await expect(row.getByText("否")).toBeVisible();
});
});
+1 -1
View File
@@ -296,7 +296,7 @@ export function deleteGroup(id: string) {
export function addGroupMembers(id: string, memberIds: string[]) {
return run(() =>
requestAdmin<{ failed: GroupFailed[] }>(
requestAdmin<{ failed: GroupFailed[]; added: number }>(
`/api/admin/groups/${encodeURIComponent(id)}/members`,
{
method: "POST",
+4 -2
View File
@@ -691,13 +691,14 @@ export const mockApi = {
return {};
},
async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[] }> {
async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[]; added: number }> {
requireSession();
const g = groups.find((x) => x.id === id);
if (!g) {
throw new ApiError("not_found", "群不存在", 404);
}
const failed: GroupFailed[] = [];
let added = 0;
for (const mid of memberIds) {
if (!endpoints.some((e) => e.id === mid)) {
failed.push({ id: mid, code: "not_found" });
@@ -705,9 +706,10 @@ export const mockApi = {
}
if (!g.members.some((m) => m.id === mid)) {
g.members.push({ id: mid, joined_at_ms: now() });
added++;
}
}
return { failed };
return { failed, added };
},
async removeGroupMember(id: string, endpointId: string): Promise<Record<string, never>> {
+8 -2
View File
@@ -233,9 +233,15 @@ async function onAddMembers() {
if (!ids.length) return;
const res = await addGroupMembers(detail.value.id, ids);
if (res.failed.length) {
message.warning(`部分失败:${formatFailed(res.failed)}`);
if (res.added > 0) {
message.warning(`已加入 ${res.added} 人,部分失败:${formatFailed(res.failed)}`);
} else {
message.warning(`部分失败:${formatFailed(res.failed)}`);
}
} else if (res.added === 0) {
message.info("没有新成员加入");
} else {
message.success("已加人");
message.success(`已加人 ${res.added} 人`);
}
await loadDetail(detail.value.id);
await load();