From a292e981d0c3771210e84398168f6af68e987f36 Mon Sep 17 00:00:00 2001 From: Xiage Date: Wed, 22 Apr 2026 11:38:11 +0800 Subject: [PATCH 1/3] feat: add CloseImmediately for synchronous xray instance shutdown Add CloseImmediately() function that synchronously closes xray instances instead of relying on the delayed sweeper mechanism. This prevents goroutine and memory leaks when testing high volumes of vless/vmess/trojan proxies. The sweeper's 30-second DrainTimeout caused accumulation of xray instances when tests completed faster than the timeout, leading to resource exhaustion. --- xray/xray.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/xray/xray.go b/xray/xray.go index 18929ef..1172229 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -197,6 +197,23 @@ func Close(proxyURL string) { } } +// CloseImmediately synchronously closes the xray instance and removes it from +// the servers map. Use this when you need immediate cleanup and are certain no +// other goroutines are using the instance. +func CloseImmediately(proxyURL string) { + mu.Lock() + defer mu.Unlock() + + srv, ok := servers[proxyURL] + if !ok { + return + } + if srv.Instance != nil { + srv.Instance.Close() //nolint: errcheck + } + delete(servers, proxyURL) +} + // CloseAll marks all servers as draining immediately. The sweeper will close // each one after DrainTimeout. func CloseAll() { From e2e3d3f60e1fe45bfe1c643817e4e60443590c8c Mon Sep 17 00:00:00 2001 From: Xiage Date: Wed, 22 Apr 2026 11:39:28 +0800 Subject: [PATCH 2/3] fix: make Close() synchronously close xray instances Change Close() to immediately close xray instances instead of marking them as draining with a 30-second delayed sweep. This prevents goroutine and memory leaks when testing high volumes of vless/vmess/trojan proxies where the previous delayed-close behavior caused resource accumulation. --- xray/xray.go | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/xray/xray.go b/xray/xray.go index 1172229..9700a3e 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -183,24 +183,10 @@ func tryCloseAndDelete(url string, srv *Server) { } } -// Close marks the server as draining. The sweeper goroutine will actually close -// the xray instance after DrainTimeout elapses, giving in-flight operations a -// chance to finish cleanly and preventing premature close from leaking goroutines. +// Close synchronously closes the xray instance and removes it from the servers +// map. This prevents goroutine and memory leaks when testing high volumes of +// proxies where the previous delayed-close behavior caused resource accumulation. func Close(proxyURL string) { - startSweeper() - mu.Lock() - defer mu.Unlock() - - i, ok := servers[proxyURL] - if ok && i.DrainedAt.IsZero() { - i.DrainedAt = time.Now() - } -} - -// CloseImmediately synchronously closes the xray instance and removes it from -// the servers map. Use this when you need immediate cleanup and are certain no -// other goroutines are using the instance. -func CloseImmediately(proxyURL string) { mu.Lock() defer mu.Unlock() From 24f53688a8b72f68c09ee2deaafe8cdbd673e016 Mon Sep 17 00:00:00 2001 From: Xiage Date: Wed, 22 Apr 2026 11:46:25 +0800 Subject: [PATCH 3/3] fix tests: update to match synchronous Close() behavior Update TestCloseRevivesServer to TestCloseRemovesServer since Close() now immediately removes the server from the map instead of marking it as draining. Update TestCloseIdempotent to check that server is removed from map rather than checking DrainedAt. --- xray/xray_test.go | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/xray/xray_test.go b/xray/xray_test.go index 341f7f3..c721a0f 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -57,39 +57,29 @@ func TestGetNonExistent(t *testing.T) { } } -func TestCloseRevivesServer(t *testing.T) { +func TestCloseRemovesServer(t *testing.T) { ResetForTest() - DrainTimeout = 50 * time.Millisecond - SweepInterval = 10 * time.Millisecond // Set up an active server. mu.Lock() servers["socks5://127.0.0.1:1080"] = &Server{SocksPort: 1080, DrainedAt: time.Time{}} mu.Unlock() - // Close it — marks DrainedAt non-zero. + // Close it — should immediately remove from map. Close("socks5://127.0.0.1:1080") - // Verify DrainedAt is non-zero (read through map under lock). + // Verify server is removed from map. mu.Lock() - wasZero := servers["socks5://127.0.0.1:1080"].DrainedAt.IsZero() + _, ok := servers["socks5://127.0.0.1:1080"] mu.Unlock() - if wasZero { - t.Error("expected DrainedAt to be non-zero after Close()") + if ok { + t.Error("expected server to be removed from map after Close()") } - // getServer should revive it (reset DrainedAt to zero). + // getServer should return nil since server was removed. got := getServer("socks5://127.0.0.1:1080") - if got == nil { - t.Fatal("expected server after getServer") - } - - // Verify DrainedAt is now zero — read through the map under lock. - mu.Lock() - stillZero := servers["socks5://127.0.0.1:1080"].DrainedAt.IsZero() - mu.Unlock() - if !stillZero { - t.Error("expected DrainedAt to be reset to zero after getServer (revive)") + if got != nil { + t.Fatal("expected nil after getServer on removed server") } } @@ -104,8 +94,8 @@ func TestCloseIdempotent(t *testing.T) { mu.Lock() defer mu.Unlock() - if servers["socks5://127.0.0.1:1080"].DrainedAt.IsZero() { - t.Error("expected DrainedAt non-zero") + if _, ok := servers["socks5://127.0.0.1:1080"]; ok { + t.Error("expected server to be removed from map") } }