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(进度回调,含最后一屏)
This commit is contained in:
2026-10-09 10:02:50 +08:00
parent 0623c55407
commit 7243f73d33
5 changed files with 367 additions and 25 deletions
+28 -13
View File
@@ -10,7 +10,7 @@ const logBuf = [];
// ---------------- 渲染 ----------------
const STATUS_TEXT = {
queued: '等待', running: '解析中', downloading: '下载中',
queued: '等待', running: '准备中', parsing: '解析中', downloading: '下载中',
done: '完成', failed: '失败', partial: '部分失败',
};
@@ -45,8 +45,25 @@ function renderSelState() {
function rowHtml(t) {
const total = t.totalPages || 0;
const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0);
const badgeCls = 'b-' + (t.status || 'queued');
// 解析阶段:显示「已解析 N/M 张」,而不是 0/?(否则看着像卡死)
const parsing = t.status === 'parsing';
let progCell;
if (parsing) {
const got = Number((t.pages || {})['_parsing'] || 0);
const pct = total ? Math.round((got / total) * 100) : 0;
progCell = `<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">已解析 ${got}${total ? '/' + total : ''} 张</span>
</div>`;
} else {
const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0);
progCell = `<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''}</span>
</div>`;
}
const title = t.title || t.raw || '(未解析)';
const err = t.error
? `<span class="e">${esc(t.error)}</span>`
@@ -63,13 +80,8 @@ function rowHtml(t) {
<td class="c-sel"><input type="checkbox" class="sel" data-gid="${t.gid}" ${t.selected ? 'checked' : ''} /></td>
<td class="c-gid">${esc(t.gid)}</td>
<td><div class="title-cell"><span class="t">${esc(title)}</span>${err}</div></td>
<td class="c-prog">
<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''}</span>
</div>
</td>
<td class="c-stat"><span class="badge ${badgeCls}">${STATUS_TEXT[t.status] || t.status}</span></td>
<td class="c-prog">${progCell}</td>
<td class="c-stat"><span class="badge ${'b-' + (t.status || 'queued')}">${STATUS_TEXT[t.status] || t.status}</span></td>
<td class="c-act">${acts.join(' ')}</td>
</tr>`;
}
@@ -79,11 +91,14 @@ function renderStats() {
let dn = 0, tt = 0;
for (const t of tasks) {
if (t.status === 'queued') c.queued++;
else if (t.status === 'running' || t.status === 'downloading') c.running++;
else if (t.status === 'running' || t.status === 'parsing' || t.status === 'downloading') c.running++;
else if (t.status === 'done') c.done++;
else if (t.status === 'failed' || t.status === 'partial') c.failed++;
dn += t.done || 0;
tt += t.totalPages || 0;
// 解析阶段的任务还没进入下载,done 不计入总进度
if (t.status !== 'parsing') {
dn += t.done || 0;
tt += t.totalPages || 0;
}
}
$('s-total').textContent = tasks.length;
$('s-queued').textContent = c.queued;
+24 -2
View File
@@ -273,6 +273,22 @@ type existingGallery struct {
manifest *Manifest
}
// reportParseProgress 解析阶段进度反馈。
//
// 解析大画廊要翻上百屏(每屏 20 张),全程可能好几分钟。
// 界面必须看得到在动,否则「解析中 0/?」会被当成卡死。
func (e *Engine) reportParseProgress(task Task, done, total, page int) {
task.Status = "parsing"
task.TotalPages = total
task.Done = 0
// 解析期用「已解析张数」当进度,下载阶段才是真正的完成数
task.Pages = map[string]string{}
if done > 0 {
task.Pages["_parsing"] = strconv.Itoa(done)
}
e.save(task)
}
// findExistingGallery 在下载根目录里找这个 gid 已存在的画廊。
//
// 两种匹配方式:
@@ -366,7 +382,12 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
// 没有可用清单(或清单太旧)→ 正常解析画廊页
if gallery == nil {
g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token)
g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token,
func(done, total, page int) {
// 解析阶段也要有反馈,否则大画廊(上千张 = 上百屏)
// 会长时间显示「解析中 0/?」,看着像卡死。
e.reportParseProgress(task, done, total, page)
})
if err != nil {
if ctx.Err() != nil {
e.markCanceled(task)
@@ -464,7 +485,8 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
e.log("warn", fmt.Sprintf(
"画廊 %s:本地清单的页面路径似乎已失效,重新解析画廊页", task.GID))
if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token); err == nil {
if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token,
func(done, total, page int) { e.reportParseProgress(task, done, total, page) }); err == nil {
// 用新解析结果重跑:重置页状态
st.reset(fresh)
st.scanExisting()
+2 -2
View File
@@ -110,7 +110,7 @@ func TestFetchGalleryPagePagination(t *testing.T) {
defer func() { EHBase = oldBase }()
c := NewClient(5*time.Second, "", "off", "auto")
g, err := c.FetchGalleryPage(context.Background(), gid, tok)
g, err := c.FetchGalleryPage(context.Background(), gid, tok, nil)
if err != nil {
t.Fatalf("FetchGalleryPage 失败: %v", err)
}
@@ -283,7 +283,7 @@ func TestFetchGalleryRestricted(t *testing.T) {
defer func() { EHBase = oldBase }()
c := NewClient(5*time.Second, "", "off", "auto")
_, err := c.FetchGalleryPage(context.Background(), "12345678", "abc")
_, err := c.FetchGalleryPage(context.Background(), "12345678", "abc", nil)
if err == nil {
t.Fatal("受限画廊应该报错")
}
+65 -8
View File
@@ -278,7 +278,8 @@ type Client struct {
proxyMode string // off | http | socks5
xres string // auto | org | 数字(长边上限)
limiter *Limiter
transport *http.Transport // 全局共用,只有代理配置变了才重建
limiter *Limiter
}
// NewClient 建客户端
@@ -319,6 +320,7 @@ func (c *Client) SetProxy(p, mode string) {
c.proxyMode = mode
}
c.mu.Unlock()
c.rebuildTransport() // 代理变了,Transport 必须重建
}
// SetXres 改分辨率策略
@@ -341,10 +343,32 @@ func (c *Client) snapshot() (string, string, time.Duration) {
return c.proxy, c.proxyMode, c.timeout
}
// httpClient 按当前代理配置构造 http.Client。
// 每次调用新建(配置变更即时生效,且下载与页面请求的超时不同)。
// httpClient 取可用的 http.Client。
//
// ⚠️ Transport 必须复用:如果每次请求都新建 Transport,旧的那个会被丢弃,
// 但它持有的 keep-alive 连接不会立刻释放(要等 IdleConnTimeout),
// 于是每请求一条僵尸连接堆积起来 —— 实测能到 91 条,
// 既吃代理的连接配额,又让新请求变慢。
// 所以这里只在「代理配置变化」时才重建 Transport,平时全局共用一个。
func (c *Client) httpClient(timeout time.Duration) *http.Client {
proxyURL, mode, defTimeout := c.snapshot()
c.mu.RLock()
tr := c.transport
defTimeout := c.timeout
c.mu.RUnlock()
if tr == nil {
tr = c.rebuildTransport()
}
if timeout <= 0 {
timeout = defTimeout
}
return &http.Client{Transport: tr, Timeout: timeout}
}
// rebuildTransport 按当前代理配置重建 Transport,并关掉旧连接的空闲池
func (c *Client) rebuildTransport() *http.Transport {
proxyURL, mode, _ := c.snapshot()
tr := &http.Transport{
MaxIdleConns: 64,
@@ -374,10 +398,26 @@ func (c *Client) httpClient(timeout time.Duration) *http.Client {
}
}
if timeout <= 0 {
timeout = defTimeout
c.mu.Lock()
old := c.transport
c.transport = tr
c.mu.Unlock()
if old != nil {
old.CloseIdleConnections()
}
return tr
}
// Close 关闭空闲连接(退出时调用)
func (c *Client) Close() {
c.mu.Lock()
tr := c.transport
c.transport = nil
c.mu.Unlock()
if tr != nil {
tr.CloseIdleConnections()
}
return &http.Client{Transport: tr, Timeout: timeout}
}
func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) {
@@ -419,9 +459,19 @@ func (c *Client) getText(ctx context.Context, u string) (string, error) {
// ---------------------------------------------------------------- 1) 画廊页
// ParseProgress 解析进度回调
//
// 大画廊要翻很多页(2000 张 = 100 页),没有进度反馈的话界面会一直显示
// 「解析中 0/?」,看着像卡死了。
//
// done = 已收集到的图片数
// total = 站点声明的总图数(第一页才知道,可能为 0)
// page = 当前翻到第几屏(每屏 20 张)
type ParseProgress func(done, total, page int)
// FetchGalleryPage 取画廊页。注意:EH 每屏只列 20 张缩略图,
// 必须翻 ?p=N 逐屏收集,否则静默只下到前 20 张且不报错。
func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gallery, error) {
func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string, onProgress ParseProgress) (*Gallery, error) {
var (
pages []GalleryPageInfo
seen = map[int]bool{}
@@ -486,6 +536,13 @@ func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gall
if added == 0 {
break // 这屏没新东西 -> 到头了
}
// 报告进度:大画廊翻几十上百屏时,界面上要看得见在动
// (放在 break 之前,保证最后一屏也会报告)
if onProgress != nil {
onProgress(len(pages), total, p+1)
}
if total > 0 && len(pages) >= total {
break // 收齐
}
+248
View File
@@ -0,0 +1,248 @@
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)
}
}