Files
eh-downloader/internal/eh/transport_test.go
T
toor 7243f73d33 fix: 解析大画廊时连接泄漏 + 解析阶段无进度反馈
用户反馈:点开始下载后 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(进度回调,含最后一屏)
2026-10-09 10:02:50 +08:00

248 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}