fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。 改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。 线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。 凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。 验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
This commit is contained in:
1 parent
d81635672e
commit
b4d55f412e
23 files changed
+1440
-27
No files matched your search
@@ -106,9 +106,13 @@ type Adapter struct {
|
||||
httpAPI *obHTTPClient
|
||||
started bool
|
||||
connected bool
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
// lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
|
||||
// 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
|
||||
// 原因不该继续挂在当前状态旁边。由 mu 保护。
|
||||
lastConnectErr string
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
|
||||
handlerMu sync.RWMutex
|
||||
handler adapter.MessageHandler
|
||||
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
|
||||
a.ln = nil
|
||||
a.srv = nil
|
||||
a.httpAPI = nil
|
||||
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
|
||||
a.lastConnectErr = ""
|
||||
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
|
||||
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。
|
||||
a.account = accountStatus{}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
|
||||
// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
|
||||
//
|
||||
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
|
||||
// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
|
||||
// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
|
||||
// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
|
||||
// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
|
||||
// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
|
||||
//
|
||||
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
|
||||
// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
|
||||
// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
|
||||
// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
|
||||
// 「这里原本是凭据」而不是「这里本来就长这样」)。
|
||||
const maskedCredential = "[redacted]"
|
||||
|
||||
// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
|
||||
//
|
||||
// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
|
||||
// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
|
||||
// **脱敏**原因,绝不含 access_token。
|
||||
//
|
||||
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
|
||||
// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
|
||||
func (a *Adapter) ConnectOutcome() (state, reason string) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return adapter.ConnectStateOf(adapter.ConnectSignals{
|
||||
Started: a.started,
|
||||
Connected: a.connected,
|
||||
// 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
|
||||
// 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
|
||||
Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
|
||||
// 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
|
||||
// Connect 直接置 connected=true,走不到这里)。
|
||||
Listening: a.ln != nil,
|
||||
Reason: a.lastConnectErr,
|
||||
})
|
||||
}
|
||||
|
||||
// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
|
||||
func (a *Adapter) connectFailureReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
|
||||
}
|
||||
|
||||
// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
|
||||
//
|
||||
// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
|
||||
// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
|
||||
// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
|
||||
// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
|
||||
// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
|
||||
// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
|
||||
// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
|
||||
//
|
||||
// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
|
||||
// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
|
||||
//
|
||||
// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
|
||||
func sanitizeConnectReason(raw, token string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
raw = trace.RedactText(raw)
|
||||
if token != "" {
|
||||
raw = strings.ReplaceAll(raw, token, maskedCredential)
|
||||
}
|
||||
return maskAccessTokenQuery(raw)
|
||||
}
|
||||
|
||||
// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
|
||||
// 处理多次出现(同一错误串里可能带多条 URL)。
|
||||
//
|
||||
// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
|
||||
// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
|
||||
func maskAccessTokenQuery(raw string) string {
|
||||
const key = "access_token="
|
||||
var b strings.Builder
|
||||
rest := raw
|
||||
for {
|
||||
i := strings.Index(strings.ToLower(rest), key)
|
||||
if i < 0 {
|
||||
b.WriteString(rest)
|
||||
return b.String()
|
||||
}
|
||||
start := i + len(key)
|
||||
end := start
|
||||
for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
|
||||
end++
|
||||
}
|
||||
b.WriteString(rest[:start])
|
||||
b.WriteString(maskedCredential)
|
||||
rest = rest[end:]
|
||||
}
|
||||
}
|
||||
|
||||
// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
|
||||
// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
|
||||
func isQueryValueDelimiter(c byte) bool {
|
||||
switch c {
|
||||
case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种
|
||||
// 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。
|
||||
//
|
||||
// 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给
|
||||
// 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene
|
||||
// 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。
|
||||
// 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何
|
||||
// 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼
|
||||
// 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。
|
||||
type tokenLeakDialer struct{ rawURL string }
|
||||
|
||||
func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("test: dial %s failed", d.rawURL)
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现,
|
||||
// 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。
|
||||
func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const token = "s3cr3t-token-value"
|
||||
raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused"
|
||||
got := sanitizeConnectReason(raw, token)
|
||||
|
||||
if strings.Contains(got, token) {
|
||||
t.Fatalf("脱敏后仍含 access_token 明文: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉:
|
||||
// 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有
|
||||
// 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。
|
||||
//
|
||||
// (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据,
|
||||
// 所以这里只断言**凭据绝不出现**,不绑定具体措辞。)
|
||||
func TestSanitizeConnectReasonStripsShortToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const token = "ab12"
|
||||
for _, raw := range []string{
|
||||
"dial ws://h/p?access_token=ab12&self_id=1 failed",
|
||||
"websocket: dial ws://h/p?access_token=ab12: connection refused",
|
||||
} {
|
||||
got := sanitizeConnectReason(raw, token)
|
||||
if strings.Contains(got, token) {
|
||||
t.Errorf("短 token 泄露: %q → %q", raw, got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=`
|
||||
// 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时,
|
||||
// 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为
|
||||
// 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。
|
||||
//
|
||||
// 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级
|
||||
// **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住
|
||||
// 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。
|
||||
// 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。
|
||||
func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const leaked = "ab12"
|
||||
raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake"
|
||||
got := sanitizeConnectReason(raw, "")
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。
|
||||
func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sanitizeConnectReason("", "tok"); got != "" {
|
||||
t.Fatalf("空原因 = %q, want 空串", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client
|
||||
// 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending
|
||||
// 并带上**脱敏后**的原因;日志里同样不许出现 token。
|
||||
func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "super-secret-token"
|
||||
const rawURL = "ws://127.0.0.1:10301/onebot"
|
||||
store := &testLogStore{}
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: rawURL,
|
||||
AccessToken: token,
|
||||
DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial,
|
||||
Logger: slog.New(&testLogHandler{store: store}),
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStatePending {
|
||||
t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending)
|
||||
}
|
||||
if state == adapter.ConnectStateConnected {
|
||||
t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来")
|
||||
}
|
||||
if strings.Contains(reason, token) {
|
||||
t.Fatalf("原因串泄露 access_token: %q", reason)
|
||||
}
|
||||
assertNoTokenLeak(t, store, token)
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因
|
||||
// 可挂(「已连接」旁边不该留一条过期的失败消息)。
|
||||
func TestConnectOutcomeClientConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv, _ := newFakeOneBotServer(t)
|
||||
defer srv.Close()
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
|
||||
DialContext: (&net.Dialer{}).DialContext,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStateConnected || reason != "" {
|
||||
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected)
|
||||
}
|
||||
if !a.IsConnected() {
|
||||
t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时
|
||||
// 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。
|
||||
func TestConnectOutcomeServerReadyThenConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"})
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
if a.IsConnected() {
|
||||
t.Fatal("还没有对端接入就报 IsConnected()=true")
|
||||
}
|
||||
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" {
|
||||
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady)
|
||||
}
|
||||
|
||||
client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "")
|
||||
if err != nil {
|
||||
t.Fatalf("dial server error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if !waitUntil(3*time.Second, a.IsConnected) {
|
||||
t.Fatal("对端接入后仍未报已连接")
|
||||
}
|
||||
if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected {
|
||||
t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因
|
||||
// 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。
|
||||
func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv, _ := newFakeOneBotServer(t)
|
||||
defer srv.Close()
|
||||
gate := make(chan struct{})
|
||||
dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext}
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
|
||||
DialContext: dial.dial,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" {
|
||||
t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason)
|
||||
}
|
||||
|
||||
close(gate) // 对面起来了
|
||||
if !waitUntil(3*time.Second, a.IsConnected) {
|
||||
t.Fatal("第 2 次拨号成功却没有自动连上")
|
||||
}
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStateConnected || reason != "" {
|
||||
t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经
|
||||
// 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。
|
||||
func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://127.0.0.1:1/",
|
||||
DialContext: (&countingDialer{failTimes: math.MaxInt}).dial,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
if err := a.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect() error = %v", err)
|
||||
}
|
||||
if state, reason := a.ConnectOutcome(); state != "" || reason != "" {
|
||||
t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过,
|
||||
// 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。
|
||||
func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{Mode: modeClient})
|
||||
err := a.Connect(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("缺 remote_url 的 Connect() = nil, want error")
|
||||
}
|
||||
if !errors.Is(err, errClientConfig) {
|
||||
t.Fatalf("Connect() error = %v, want errClientConfig", err)
|
||||
}
|
||||
if state, _ := a.ConnectOutcome(); state != "" {
|
||||
t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state)
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
|
||||
s.failures = 0
|
||||
}
|
||||
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的
|
||||
// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
|
||||
// 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
|
||||
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
|
||||
// 既不落日志、也不进对外文案。
|
||||
func (a *Adapter) noteConnectFailure(err error) {
|
||||
delay := a.reconnect.fail(a.policy)
|
||||
a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay)
|
||||
reason := a.connectFailureReason(err)
|
||||
a.mu.Lock()
|
||||
a.lastConnectErr = reason
|
||||
a.mu.Unlock()
|
||||
a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
|
||||
}
|
||||
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
|
||||
old := a.conn
|
||||
a.conn = conn
|
||||
a.connected = true
|
||||
// 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
|
||||
// 过期的原因挂在「已连接」旁边。
|
||||
a.lastConnectErr = ""
|
||||
a.mu.Unlock()
|
||||
if old != nil && old != conn {
|
||||
_ = old.Close()
|
||||
|
||||
Reference in new issue
Block a user