用户反馈:点开始下载后 6 个画廊(都是 2000 张级)一直显示「解析中 0/?」, 看起来像卡死。排查发现两个问题: 1. 连接泄漏(真 bug) httpClient() 每次请求都新建 http.Transport,用完丢弃。Transport 被丢弃后 它持有的 keep-alive 连接不会立刻释放(要等 IdleConnTimeout=90s), 于是每发一个请求留下一条僵尸连接。 现场实测:91 条 Established 连接,而解析是串行的,正常应该只有 1 条。 修复:Transport 全局复用,只在代理配置变化时重建, 重建时 CloseIdleConnections 关掉旧连接;新增 Client.Close()。 实测:20 次请求从 20 条连接降到 1 条;100 次并发请求只用 22 条。 2. 解析阶段毫无反馈 2000 张 = 100 屏串行翻页,整个解析阶段界面都显示「解析中 0/?」, 完全没有进度,用户只能干等。 修复:FetchGalleryPage 增加 ParseProgress 回调,每翻完一屏上报一次 (已解析张数/总张数/当前屏),界面显示「已解析 340/2000 张」。 另外:用户设置里请求间隔是 1000ms(1 秒),全局限流让 5 个画廊共享 1 请求/秒 —— 500 次解析请求需要约 8 分钟。这是配置导致的慢,不是 bug, 但可以在界面上看到进度后就不会以为卡死了。 测试从 74 增至 80,新增: - TestTransportIsReused / TestTransportReusedUnderConcurrency(连接复用) - TestSetProxyRebuildsTransport(改代理要重建) - TestParseProgressCallback(进度回调,含最后一屏)
248 lines
6.4 KiB
Go
248 lines
6.4 KiB
Go
package eh
|
||
|
||
// 连接复用测试
|
||
//
|
||
// 曾经的 bug:httpClient() 每次请求都新建 http.Transport,用完丢弃。
|
||
// Transport 被丢弃后,它持有的 keep-alive 连接不会立刻释放(要等
|
||
// IdleConnTimeout = 90s),于是每发一个请求就留下一条僵尸连接。
|
||
//
|
||
// 实测踩坑现场:解析大画廊时 91 条 Established 连接,而解析是串行的,
|
||
// 正常应该只有 1 条。连接堆积会吃代理的连接配额,也让新请求变慢。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// buildGalleryHTML 造一页画廊 HTML
|
||
func buildGalleryHTML(gid string, from, to, total, screen int) string {
|
||
var sb strings.Builder
|
||
if screen == 0 {
|
||
sb.WriteString(`<!DOCTYPE html><html><head><title>[Test] - E-Hentai Galleries</title>`)
|
||
sb.WriteString(`<meta name="description" content="Free Hentai Doujinshi Gallery: [Test]: x" />`)
|
||
sb.WriteString(`</head><body><div id="taglist"><table></table></div>`)
|
||
}
|
||
for i := from; i <= to; i++ {
|
||
sb.WriteString(fmt.Sprintf(
|
||
`<a href="/s/k%03d/%s-%d"><div title="Page %d: %03d.png"></div></a>`,
|
||
i, gid, i, i, i))
|
||
}
|
||
sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, total))
|
||
if screen == 0 {
|
||
sb.WriteString(`</body></html>`)
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// 统计服务端实际接受的连接数
|
||
func TestTransportIsReused(t *testing.T) {
|
||
var conns int64
|
||
|
||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte("ok"))
|
||
}))
|
||
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
||
if state == http.StateNew {
|
||
atomic.AddInt64(&conns, 1)
|
||
}
|
||
}
|
||
srv.Start()
|
||
defer srv.Close()
|
||
|
||
c := NewClient(10*time.Second, "", "off", "auto")
|
||
|
||
const n = 20
|
||
for i := 0; i < n; i++ {
|
||
if _, err := c.getText(context.Background(), srv.URL+"/x"); err != nil {
|
||
t.Fatalf("第 %d 次请求失败: %v", i+1, err)
|
||
}
|
||
}
|
||
|
||
got := atomic.LoadInt64(&conns)
|
||
// 连接复用后,20 次请求应该只用 1 条连接(串行)。
|
||
// 修复前是每请求一条 = 20 条。
|
||
if got > 3 {
|
||
t.Errorf("%d 次请求开了 %d 条连接 —— Transport 没复用,连接在泄漏", n, got)
|
||
}
|
||
t.Logf("%d 次请求共用了 %d 条连接", n, got)
|
||
}
|
||
|
||
// 并发请求也应该复用连接,而不是每个 goroutine 一条
|
||
func TestTransportReusedUnderConcurrency(t *testing.T) {
|
||
var conns int64
|
||
|
||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte("ok"))
|
||
}))
|
||
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
||
if state == http.StateNew {
|
||
atomic.AddInt64(&conns, 1)
|
||
}
|
||
}
|
||
srv.Start()
|
||
defer srv.Close()
|
||
|
||
c := NewClient(10*time.Second, "", "off", "auto")
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 20; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for j := 0; j < 5; j++ {
|
||
c.getText(context.Background(), srv.URL+"/x")
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
|
||
got := atomic.LoadInt64(&conns)
|
||
// 100 次并发请求,连接数应该远小于 100
|
||
if got > 60 {
|
||
t.Errorf("100 次并发请求开了 %d 条连接,复用不充分", got)
|
||
}
|
||
t.Logf("100 次并发请求共用了 %d 条连接", got)
|
||
}
|
||
|
||
// Close() 要真的能关掉空闲连接
|
||
func TestClientCloseReleasesConnections(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte("ok"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := NewClient(10*time.Second, "", "off", "auto")
|
||
for i := 0; i < 5; i++ {
|
||
c.getText(context.Background(), srv.URL+"/x")
|
||
}
|
||
|
||
c.mu.RLock()
|
||
tr := c.transport
|
||
c.mu.RUnlock()
|
||
if tr == nil {
|
||
t.Fatal("Transport 应该是复用的,不该为 nil")
|
||
}
|
||
|
||
c.Close()
|
||
|
||
c.mu.RLock()
|
||
still := c.transport
|
||
c.mu.RUnlock()
|
||
if still != nil {
|
||
t.Error("Close() 后 transport 应被清空")
|
||
}
|
||
}
|
||
|
||
// 改代理配置后 Transport 要重建(否则还是走老代理)
|
||
func TestSetProxyRebuildsTransport(t *testing.T) {
|
||
c := NewClient(5*time.Second, "", "off", "auto")
|
||
|
||
// 先建一个
|
||
c.getText(context.Background(), "http://127.0.0.1:1/never") // 必然失败,只为触发建 Transport
|
||
c.mu.RLock()
|
||
first := c.transport
|
||
c.mu.RUnlock()
|
||
if first == nil {
|
||
t.Fatal("应该已经建好 Transport")
|
||
}
|
||
|
||
// 改代理
|
||
c.SetProxy("http://127.0.0.1:7890", "http")
|
||
|
||
c.mu.RLock()
|
||
second := c.transport
|
||
c.mu.RUnlock()
|
||
if second == first {
|
||
t.Error("改代理后 Transport 必须重建,否则还走旧配置")
|
||
}
|
||
if second == nil {
|
||
t.Error("重建后不该是 nil")
|
||
}
|
||
}
|
||
|
||
// 解析进度回调要真的被调用
|
||
func TestParseProgressCallback(t *testing.T) {
|
||
const gid, tok, total = "4239999", "abcdef1234", 45
|
||
|
||
pages := map[int]string{}
|
||
for p := 0; p < 3; p++ {
|
||
from, to := p*20+1, p*20+20
|
||
if to > total {
|
||
to = total
|
||
}
|
||
page := p
|
||
pages[page] = buildGalleryHTML(gid, from, to, total, page)
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) {
|
||
p := 0
|
||
if q := r.URL.Query().Get("p"); q != "" {
|
||
fmt.Sscanf(q, "%d", &p)
|
||
}
|
||
h, ok := pages[p]
|
||
if !ok {
|
||
h = buildGalleryHTML(gid, 0, 0, total, 9)
|
||
}
|
||
w.Write([]byte(h))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
defer srv.Close()
|
||
|
||
old := EHBase
|
||
EHBase = srv.URL
|
||
defer func() { EHBase = old }()
|
||
|
||
c := NewClient(5*time.Second, "", "off", "auto")
|
||
|
||
var got []int
|
||
var maxDone int
|
||
g, err := c.FetchGalleryPage(context.Background(), gid, tok, func(done, tot, page int) {
|
||
got = append(got, done)
|
||
if done > maxDone {
|
||
maxDone = done
|
||
}
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("解析失败: %v", err)
|
||
}
|
||
if len(got) < 3 {
|
||
t.Errorf("应该至少回调 3 次(翻 3 屏),实际 %d 次", len(got))
|
||
}
|
||
if maxDone != total {
|
||
t.Errorf("最后一次进度应达到 %d,实际 %d", total, maxDone)
|
||
}
|
||
if g.TotalPages != total {
|
||
t.Errorf("总页数不对: %d", g.TotalPages)
|
||
}
|
||
t.Logf("进度回调 %d 次: %v", len(got), got)
|
||
}
|
||
|
||
// 传 nil 回调也不该 panic
|
||
func TestParseProgressNil(t *testing.T) {
|
||
const gid, tok, total = "4239998", "abcdef1234", 20
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte(buildGalleryHTML(gid, 1, 20, total, 0)))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
defer srv.Close()
|
||
|
||
old := EHBase
|
||
EHBase = srv.URL
|
||
defer func() { EHBase = old }()
|
||
|
||
c := NewClient(5*time.Second, "", "off", "auto")
|
||
if _, err := c.FetchGalleryPage(context.Background(), gid, tok, nil); err != nil {
|
||
t.Errorf("nil 回调不该出错: %v", err)
|
||
}
|
||
} |