diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 7deb4c5..a2c31ab 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1718,3 +1718,13 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是 - 原因:原先 12 项备注写着未穷尽仍标通过,交付说明写成「通过 23」。 - 备选方案:为每个未测子项补验收用例(本波不做,避免为变绿放松断言)。 - 影响:汇总改为通过 19、部分通过 4(F03/F08/F21/F22)、失败 0。F19 仍引用仓库内 SDK 清单、本波不重跑。 + +### 复审修复 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` 已变。 diff --git a/sdk/js/src/client.ts b/sdk/js/src/client.ts index bd0cbb1..88f9214 100644 --- a/sdk/js/src/client.ts +++ b/sdk/js/src/client.ts @@ -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); diff --git a/sdk/js/test/client.test.ts b/sdk/js/test/client.test.ts index ad083f1..6f66cf1 100644 --- a/sdk/js/test/client.test.ts +++ b/sdk/js/test/client.test.ts @@ -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> = []; + let failOnce = true; + fake.publishUpImpl = async (s) => { + const m = JSON.parse(s) as Record; + 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");