Files
webui4frpc/internal/cluster/ring_revoke_test.go
JianFeeeee 041cc04dd6 fix(cluster): revoke 在无 topology 条目时静默失效 + 停用状态跨节点不同步
上一提交(46e8bc3)上线后实测:`.106` 重启后,被用户停用的
`minecraft` worker **仍然被拉起**。继续挖出两处,均为静默失效型。

## 1. Handler.Revoke 被 RemoveTopology 的返回值挡住了

    if claimed.Revoke {
        if e.state.RemoveTopology(...) {      // ← false 时整个块跳过
            if e.Handler != nil { e.Handler.Revoke(...) }
        }
    }

停 worker 的唯一动作被关在「topology 里确实有条目」的条件里。而
RemoveTopology 对**不在 topology 的转发返回 false** —— 也就是说,越是
需要清理的僵尸转发(条目已丢、worker 还在),revoke 越是完全不做。

这正是 minecraft 的处境:停用请求到达时它已不在 topology ⇒ 返回 false
⇒ Revoke 不执行 ⇒ worker 永远不停 ⇒ 每 33 秒刷一次 connection refused。

修法:停 worker 与摘条目是**两件独立的事**,条目不在也照样停。
(审计日志 forward.remove 仍只在真的摘掉条目时写,这是对的。)

## 2. 停用状态是节点本地的,从不跨节点同步

`links` 表每节点各一份。实测同一时刻 `.60` 记 disabled=1、`.106` 记
disabled=0 —— 因为只有收到停用请求的那个节点写了 store,而**持有 worker
的 owner 往往是另一台机器**,它的副本仍是"启用"。Claim 只读本地 store
⇒ owner 认为该转发是启用的 ⇒ 又被拉起。

修法:task 已携带 Disabled,以它为准,RevokeFn 在本节点把它落库
(已有行改写;没有行则补一条 disabled 记录,防止日后在本节点被 claim
时复活)。

## 测试

- TestRevokeIdempotent 原先只断言"不 panic",正好漏掉这个 bug —— 它
  允许「Handler.Revoke 从不调用」通过。现补上断言:拓扑里没有条目时
  **仍必须调用** Handler.Revoke。
- 验证该测试有效:把 `&& removed` gate 加回去 → 如期变红;还原后变绿。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:05:55 +08:00

83 lines
3.1 KiB
Go

package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
// TestRevokeTaskRemovesTopology: a revoke task published to the ring removes
// the forward from topology, calls Handler.Revoke, and logs forward.remove.
func TestRevokeTaskRemovesTopology(t *testing.T) {
revoked := false
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
revoke: func(ctx context.Context, tk *Task) error { revoked = true; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true, "")
// establish a forward
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
// lowest-load is n1 (only node): it claims + builds topology
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 1 {
t.Fatalf("topology after claim = %+v", eng.state.TopologyList())
}
// publish a revoke task pointing at the same forward
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 0 {
t.Fatalf("topology after revoke = %+v", eng.state.TopologyList())
}
if !revoked {
t.Fatal("Handler.Revoke was not called")
}
// log should contain forward.remove
var sawRemove bool
for _, e := range eng.Log.Snapshot() {
if e.Kind == LogForwardRemove {
sawRemove = true
}
}
if !sawRemove {
t.Fatalf("log missing forward.remove: %+v", eng.Log.Snapshot())
}
}
// TestRevokeIdempotent: revoking an already-missing forward does not error.
//
// It must ALSO still call Handler.Revoke. The handler is what actually stops
// the per-forward frpc worker; the topology entry is only bookkeeping. Gating
// the handler on RemoveTopology()'s return value (as this test used to permit)
// turned every revoke of an already-absent forward into a silent no-op — the
// worker kept running, which is how a forward the user had stopped kept
// dialling a local service that was intentionally down, forever.
func TestRevokeIdempotent(t *testing.T) {
revoked := 0
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
revoke: func(ctx context.Context, tk *Task) error { revoked++; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true, "")
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatalf("revoke missing: %v", err)
}
// no topology entry, no panic
if len(eng.state.TopologyList()) != 0 {
t.Fatal("should be empty")
}
// The stop side-effect must have happened even though there was no entry.
if revoked != 1 {
t.Fatalf("Handler.Revoke called %d times, want 1 — a revoke with no topology entry "+
"must still stop the worker, otherwise stopped forwards keep running", revoked)
}
}