fix(网关): 占住幂等键后的早退路径没退键 —— 永久占键、重试永远 duplicate
## 缺陷
`ClaimRelay` 成功之后、邮件落库之前有若干条早退路径,只有**部分**在 return 前
调了 `ReleaseRelay`。漏掉的那些会把 (agent_name, relay_key) 永久占住:
之后同一上游消息的重试拿到 `ErrRelayDuplicate` ⇒ 返回 200 `duplicate_relay`
(文案是「该上游消息已转发过,本次调用未产生新邮件」),
而那条消息**从未发出去** —— 响应在陈述一件没发生的事。
野外实例(现库仍在,`relayed_mails` 唯一一行 `mail_id IS NULL`):
agent_name = zcode
relay_key = no-such-session-0000:toolu-nohuman-1789193173578
created_at = 2026-09-12 06:06:13
它的 session 位不是 UUID,于是 `RequestPermission` 走到
`uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)直接 return,
没有退键。该行从 09-12 留到现在。
`mail.go` 同类:`ClaimRelay`(:396)之后 `ConsumeSessionBudget` 返回
非「额度耗尽」错误时(:433)也没退键。
## 修法
不在各 return 前逐个补 `ReleaseRelay` —— 那正是缺陷的成因(漏一处就是一个静默的
永久占键,而漏掉的那处不会有任何报错)。改为占键成功后立刻 `defer` 一次释放,
让"还键"成为该作用域的唯一出口不变量。
成功路径不需要额外开关:`ReleaseRelay` 的 WHERE 含 `mail_id IS NULL`,
`BindRelayMail` 一旦成功该行就不再匹配,defer 自然什么都不做。
## 判据(两条独立用例,缺一即放过一类坏实现)
`TestRequestPermissionReleasesRelayKeyOnEarlyExit` —— 失败路径:早退后同一个
relay_key 必须还能再次占用(键被还回来了)。
`TestRequestPermissionClaimsRelayKeyOnSuccess` —— 成功路径:同一询问重复投递
必须被幂等键挡住,且键确实绑定到了第一封邮件上。
两条必须分开:早退时"正确实现"与"把 ClaimRelay 换成空操作"的坏实现在表里
**观测等价**(都留下一个空键),单条用例无法同时钉住"该退的时候退"和
"该占的时候占"。已逐个变异验证:
基线(缺陷代码) → 早退用例红、成功用例绿
早退处补 Release → 两条全绿
ClaimRelay 置空 → 成功用例红(早退用例仍绿,符合预期)
BindRelayMail 去掉 → 两条全红
`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
This commit is contained in:
@ -406,6 +406,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
// 键已占住,此后**每一条**早退路径都必须把它还回去,否则这条
|
||||
// (agent_name, relay_key) 被永久占用:上游那条消息再也转不出来,
|
||||
// 重试只会拿到 duplicate_relay(复用「已转发过」那条响应)。
|
||||
//
|
||||
// 用 defer 而不是在各 return 前逐个补:这条路上有多处早退
|
||||
// (预算耗尽、预算检查出错、建邮件失败……),逐个补漏掉一处就是一个
|
||||
// 静默的永久占键。与 permission.go 同因同法。
|
||||
//
|
||||
// 成功路径不需要额外开关:ReleaseRelay 只删 `mail_id IS NULL` 的占位行,
|
||||
// BindRelayMail 一旦成功,这行就不再匹配,defer 自然什么都不做。
|
||||
defer func() {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}()
|
||||
}
|
||||
|
||||
if relayFree {
|
||||
|
||||
@ -102,6 +102,22 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
// 键已占住,此后**每一条**早退路径都必须把它还回去,否则这条
|
||||
// (agent_name, relay_key) 被永久占用:之后的重试只会拿到
|
||||
// duplicate_relay(复用「已转发过」那条响应),而这次询问其实从未发出。
|
||||
//
|
||||
// 用 defer 而不是在各 return 前逐个补 ReleaseRelay:这条路上有多处早退
|
||||
// (session_id 不是 UUID、建会话失败……),逐个补漏掉一处就是一个静默的
|
||||
// 永久占键。野外已有一例:relay_key
|
||||
// `no-such-session-0000:toolu-nohuman-1789193173578` 的 session 位不是
|
||||
// UUID,走到 `Invalid session_id` 那条 return 时没退键,2026-09-12 起
|
||||
// 一直留在表里(mail_id 为空)。
|
||||
//
|
||||
// 成功路径不需要额外开关:ReleaseRelay 只删 `mail_id IS NULL` 的占位行,
|
||||
// 而下面 BindRelayMail 一旦成功,这行就不再匹配,defer 自然什么都不做。
|
||||
defer func() {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}()
|
||||
}
|
||||
|
||||
// 确定 session
|
||||
|
||||
144
server/internal/handler/permission_relay_release_test.go
Normal file
144
server/internal/handler/permission_relay_release_test.go
Normal file
@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// 占住幂等键之后,**任何**一条早退路径都必须把键还回去;同时幂等性本身不能坏掉。
|
||||
//
|
||||
// 背景(真实数据):现库里 `relayed_mails` 有一行孤儿键 ——
|
||||
//
|
||||
// agent_name = zcode
|
||||
// relay_key = no-such-session-0000:toolu-nohuman-1789193173578
|
||||
// mail_id = NULL (= 占位成功,但从未绑定到任何邮件)
|
||||
// created_at = 2026-09-12 06:06:13
|
||||
//
|
||||
// 该键的 session 位不是 UUID,于是 `RequestPermission` 走到
|
||||
// `uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)时直接 return,
|
||||
// **没有调用 ReleaseRelay**。键因此永久留在表里:之后任何用同一个
|
||||
// (agent_name, relay_key) 的重试都会拿到 `ErrRelayDuplicate`(→ 200
|
||||
// duplicate_relay),而那次询问其实**从未发出去**。
|
||||
//
|
||||
// 为什么必须是两个独立的测试:这两件事在**早退路径**上观测等价。
|
||||
// 早退时正确实现与"把 ClaimRelay 换成空操作"的坏实现**都**在表里留下一个空键,
|
||||
// 所以任何单条用例都无法同时钉住"该退的时候退"和"该占的时候占"。
|
||||
// 前者只能在**失败路径**上观测(键本该被还回来),
|
||||
// 后者只能在**成功路径**上观测(键本该被占住)。
|
||||
|
||||
// 失败路径:早退之后同一个 relay_key 必须还能再次被占用(键被还回来了)。
|
||||
func TestRequestPermissionReleasesRelayKeyOnEarlyExit(t *testing.T) {
|
||||
setupPermissionHandlerDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
|
||||
t.Fatalf("注册 pi: %v", err)
|
||||
}
|
||||
|
||||
// 非 UUID 的 session 位:这正是野外那行孤儿键的形态,也是走到早退的入口。
|
||||
const relayKey = "no-such-session-0000:toolu-nohuman-1789193173578"
|
||||
resp := requestPermissionRaw(t, map[string]any{
|
||||
"session_id": "no-such-session-0000",
|
||||
"to": "human",
|
||||
"question": "是否允许执行 bash?",
|
||||
"options": []string{"同意", "拒绝"},
|
||||
"relay_key": relayKey,
|
||||
})
|
||||
|
||||
// 前提:请求确实失败(否则这个用例没验到早退那条路)。
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非 UUID 的 session_id 必须 400,实际 HTTP %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// 断言:键被还回来了 —— 同一个键必须能再次占用。
|
||||
if err := repo.ClaimRelay(ctx, "pi", relayKey, "permission"); err != nil {
|
||||
t.Fatalf("早退路径没把幂等键还回去:再次占用同一个键被拒(%v)—— "+
|
||||
"该 (agent, relay_key) 已被永久占用,之后的重试只会拿到 duplicate_relay,"+
|
||||
"而那次询问从未发出", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功路径:幂等键必须真的被占住 —— 同一条询问重复投递只能生成一封邮件。
|
||||
//
|
||||
// 这条与上一条是**互补**的,不是重复:把 `ClaimRelay` 换成空操作会让上一条全绿
|
||||
// (键从未被占 ⇒ "还能再占"天然成立),只有这条会红。
|
||||
func TestRequestPermissionClaimsRelayKeyOnSuccess(t *testing.T) {
|
||||
setupPermissionHandlerDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
|
||||
t.Fatalf("注册 pi: %v", err)
|
||||
}
|
||||
mustPermissionUser(t, "owner")
|
||||
sid, err := repo.CreateSession(ctx, nil, "pi", "权限幂等", "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if err := repo.SetSessionOwner(ctx, sid, permissionUserID(t, "owner")); err != nil {
|
||||
t.Fatalf("设置 owner: %v", err)
|
||||
}
|
||||
if _, err := repo.CreateMail(ctx, sid, nil, "owner", "", "pi", "/tmp",
|
||||
"任务", "请执行", nil); err != nil {
|
||||
t.Fatalf("建线索邮件: %v", err)
|
||||
}
|
||||
|
||||
const relayKey = "sess-1:toolu-repeat-1"
|
||||
body := map[string]any{
|
||||
"session_id": sid.String(),
|
||||
"to": "owner",
|
||||
"question": "是否允许执行 bash?",
|
||||
"options": []string{"同意", "拒绝"},
|
||||
"relay_key": relayKey,
|
||||
}
|
||||
|
||||
first := requestPermissionRaw(t, body)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("首次询问必须受理,HTTP %d: %s", first.Code, first.Body.String())
|
||||
}
|
||||
|
||||
// 第二次同一 relay_key:幂等要求它**不产生新邮件**。
|
||||
second := requestPermissionRaw(t, body)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("重复投递应幂等返回 200,HTTP %d: %s", second.Code, second.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("解析响应: %v", err)
|
||||
}
|
||||
if got["status"] != "duplicate_relay" {
|
||||
t.Fatalf("重复投递必须被幂等键挡住(status=duplicate_relay),实际 %v —— "+
|
||||
"幂等键没生效,SSE 重放与插件重试会给同一次询问发多封邮件", got["status"])
|
||||
}
|
||||
|
||||
// 并且库里那条键确实绑到了第一封邮件上(不是留在空占位状态)。
|
||||
var bound string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT mail_id FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2`,
|
||||
"pi", relayKey).Scan(&bound); err != nil {
|
||||
t.Fatalf("查幂等键绑定: %v", err)
|
||||
}
|
||||
if bound == "" {
|
||||
t.Fatal("成功路径没有把幂等键绑定到邮件上(mail_id 为空)—— " +
|
||||
"该键会被 ReleaseRelay 当成占位残留删掉,幂等随之失效")
|
||||
}
|
||||
}
|
||||
|
||||
// requestPermissionRaw 发一次 /permission/request,body 由调用方给定。
|
||||
func requestPermissionRaw(t *testing.T, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/permission/request", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := context.WithValue(req.Context(), middleware.AgentNameKey, "pi")
|
||||
resp := httptest.NewRecorder()
|
||||
RequestPermission(resp, req.WithContext(ctx))
|
||||
return resp
|
||||
}
|
||||
Reference in New Issue
Block a user