Skip to content

Commit 71e634e

Browse files
authored
Merge pull request #5 from getlantern/optimize-for-mobile
Reduce allocations and GC pressure for mobile devices
2 parents 63d0fc3 + bf15750 commit 71e634e

9 files changed

Lines changed: 203 additions & 112 deletions

File tree

bench_test.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
)
1010

1111
func BenchmarkFrontPoolTakeReturn(b *testing.B) {
12-
pool := newFrontPool()
12+
pool := newFrontPool(0)
1313
f := newFront(&Masquerade{Domain: "cdn.example.com", IpAddress: "1.2.3.4"}, "test")
1414
f.markSucceeded()
1515
pool.addReady(f)
@@ -23,7 +23,7 @@ func BenchmarkFrontPoolTakeReturn(b *testing.B) {
2323
}
2424

2525
func BenchmarkFrontPoolCandidates(b *testing.B) {
26-
pool := newFrontPool()
26+
pool := newFrontPool(0)
2727
fronts := make([]*front, 5000)
2828
for i := range fronts {
2929
fronts[i] = newFront(&Masquerade{
@@ -47,7 +47,7 @@ func BenchmarkRewriteRequest(b *testing.B) {
4747
req.Header.Set("X-Custom", "value")
4848
b.ResetTimer()
4949
for b.Loop() {
50-
_, _ = rewriteRequest(req, "d1234.cloudfront.net", nil)
50+
_ = rewriteRequest(req, "d1234.cloudfront.net", nil)
5151
}
5252
}
5353

@@ -122,6 +122,22 @@ func BenchmarkFrontsToCache(b *testing.B) {
122122
}
123123
}
124124

125+
// BenchmarkFrontsToCacheRealistic simulates a typical pool where only ~10%
126+
// of fronts have been tested and succeeded — the common case on mobile.
127+
func BenchmarkFrontsToCacheRealistic(b *testing.B) {
128+
fronts := make([]*front, 1000)
129+
for i := range fronts {
130+
fronts[i] = newFront(&Masquerade{Domain: "cdn.example.com", IpAddress: "1.2.3.4"}, "test")
131+
if i < 100 { // only 10% succeeded
132+
fronts[i].markSucceeded()
133+
}
134+
}
135+
b.ResetTimer()
136+
for b.Loop() {
137+
_ = frontsToCache(fronts, 1000)
138+
}
139+
}
140+
125141
func BenchmarkGoroutineCount(b *testing.B) {
126142
// Measure goroutine overhead of creating and closing a client
127143
config := &Config{

cache.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,24 @@ func (NopCache) Load() ([]*CachedFront, error) { return nil, nil }
5353
func (NopCache) Save([]*CachedFront) error { return nil }
5454

5555
// frontsToCache converts pool fronts to cache format, limited to maxSize entries.
56+
// Only fronts that have previously succeeded are cached — this avoids allocating
57+
// CachedFront structs for the (typically much larger) set of untested fronts.
5658
func frontsToCache(fronts []*front, maxSize int) []*CachedFront {
57-
if len(fronts) > maxSize {
58-
fronts = fronts[:maxSize]
59-
}
60-
cached := make([]*CachedFront, 0, len(fronts))
59+
cached := make([]*CachedFront, 0, min(len(fronts), maxSize))
6160
for _, f := range fronts {
61+
ts := f.lastSucceededTime()
62+
if ts.IsZero() {
63+
continue // never succeeded, skip
64+
}
65+
if len(cached) >= maxSize {
66+
break
67+
}
6268
cached = append(cached, &CachedFront{
6369
Domain: f.Domain,
6470
IpAddress: f.IpAddress,
6571
SNI: f.SNI,
6672
VerifyHostname: f.VerifyHostname,
67-
LastSucceeded: f.lastSucceededTime(),
73+
LastSucceeded: ts,
6874
ProviderID: f.ProviderID,
6975
})
7076
}

cache_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,20 @@ func TestFrontsToCache(t *testing.T) {
7474
newFront(&Masquerade{Domain: "b.com", IpAddress: "2.2.2.2"}, "p2"),
7575
newFront(&Masquerade{Domain: "c.com", IpAddress: "3.3.3.3"}, "p3"),
7676
}
77+
// Mark all as succeeded — only succeeded fronts are cached
78+
for _, f := range fronts {
79+
f.markSucceeded()
80+
}
7781

7882
cached := frontsToCache(fronts, 2)
79-
assert.Len(t, cached, 2)
83+
assert.Len(t, cached, 2, "should be limited to maxSize")
84+
85+
// Verify that never-succeeded fronts are skipped
86+
unsucceeded := []*front{
87+
newFront(&Masquerade{Domain: "d.com", IpAddress: "4.4.4.4"}, "p4"),
88+
}
89+
cached = frontsToCache(unsucceeded, 10)
90+
assert.Len(t, cached, 0, "never-succeeded fronts should not be cached")
8091
}
8192

8293
func TestFileCache_CreatesDirs(t *testing.T) {

config.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,17 @@ type Masquerade struct {
5252
// Lookup returns the fronted hostname for the given origin hostname.
5353
// Returns empty string if the provider has no mapping for the host.
5454
func (p *Provider) Lookup(hostname string) string {
55-
if h, _, err := net.SplitHostPort(hostname); err == nil {
56-
hostname = h
55+
// Strip port if present. Check for colon first to avoid net.SplitHostPort
56+
// which allocates a *AddrError for port-less hostnames (the common case).
57+
if strings.LastIndexByte(hostname, ':') >= 0 {
58+
if h, _, err := net.SplitHostPort(hostname); err == nil {
59+
hostname = h
60+
}
5761
}
58-
hostname = strings.ToLower(hostname)
62+
// Only allocate a lowercase copy when the hostname isn't already lowercase.
63+
// In practice, hostnames from Android/Go HTTP clients are almost always
64+
// lowercase, so this avoids an allocation on the hot request path.
65+
hostname = toLowerFast(hostname)
5966

6067
if alias := p.HostAliases[hostname]; alias != "" {
6168
return alias
@@ -71,6 +78,16 @@ func (p *Provider) Lookup(hostname string) string {
7178
return ""
7279
}
7380

81+
// toLowerFast returns s lowercased, reusing s if it's already all-lowercase.
82+
func toLowerFast(s string) string {
83+
for i := range s {
84+
if s[i] >= 'A' && s[i] <= 'Z' {
85+
return strings.ToLower(s)
86+
}
87+
}
88+
return s
89+
}
90+
7491
// ParseConfig parses a gzipped YAML configuration into a Config.
7592
func ParseConfig(gzippedYaml []byte) (*Config, error) {
7693
r, err := gzip.NewReader(bytes.NewReader(gzippedYaml))

dialer.go

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,31 +36,13 @@ type dialResult struct {
3636
}
3737

3838
// dialFront performs a TLS connection to the given front.
39+
// Builds the tls.Config in a single allocation (no Clone).
3940
func dialFront(ctx context.Context, f *front, rootCAs *x509.CertPool, clientHelloID tls.ClientHelloID, dialer Dialer) dialResult {
4041
addr := f.IpAddress
4142
if _, _, err := net.SplitHostPort(addr); err != nil {
4243
addr = net.JoinHostPort(addr, "443")
4344
}
4445

45-
tlsConfig := &tls.Config{
46-
ServerName: f.Domain,
47-
RootCAs: rootCAs,
48-
}
49-
50-
var sendServerNameExtension bool
51-
if f.SNI != "" {
52-
sendServerNameExtension = true
53-
tlsConfig.ServerName = f.SNI
54-
tlsConfig.InsecureSkipVerify = true
55-
tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
56-
var verifyHostname string
57-
if f.VerifyHostname != nil {
58-
verifyHostname = *f.VerifyHostname
59-
}
60-
return verifyPeerCertificate(rawCerts, rootCAs, verifyHostname)
61-
}
62-
}
63-
6446
dialCtx, dialCancel := context.WithTimeout(ctx, dialTimeout)
6547
defer dialCancel()
6648

@@ -69,18 +51,31 @@ func dialFront(ctx context.Context, f *front, rootCAs *x509.CertPool, clientHell
6951
return dialResult{nil, classifyError(err), err}
7052
}
7153

72-
configCopy := tlsConfig.Clone()
73-
configCopy.InsecureSkipVerify = true
74-
if !sendServerNameExtension {
75-
configCopy.ServerName = ""
54+
// Build final tls.Config directly — single allocation instead of alloc + Clone.
55+
config := &tls.Config{
56+
RootCAs: rootCAs,
57+
InsecureSkipVerify: true,
58+
}
59+
60+
useSNI := f.SNI != ""
61+
if useSNI {
62+
config.ServerName = f.SNI
63+
config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
64+
var verifyHostname string
65+
if f.VerifyHostname != nil {
66+
verifyHostname = *f.VerifyHostname
67+
}
68+
return verifyPeerCertificate(rawCerts, rootCAs, verifyHostname)
69+
}
7670
}
71+
// When not using SNI, ServerName stays empty so no SNI extension is sent.
7772

7873
chid := clientHelloID
7974
if chid.Client == "" {
8075
chid = tls.HelloGolang
8176
}
8277

83-
conn := tls.UClient(rawConn, configCopy, chid)
78+
conn := tls.UClient(rawConn, config, chid)
8479
deadline := time.Now().Add(dialTimeout)
8580
rawConn.SetDeadline(deadline)
8681

@@ -91,13 +86,13 @@ func dialFront(ctx context.Context, f *front, rootCAs *x509.CertPool, clientHell
9186
rawConn.SetDeadline(time.Time{})
9287

9388
// For non-SNI case, verify the cert manually after handshake
94-
if !tlsConfig.InsecureSkipVerify {
89+
if !useSNI {
9590
state := conn.ConnectionState()
9691
rawCerts := make([][]byte, len(state.PeerCertificates))
9792
for i, cert := range state.PeerCertificates {
9893
rawCerts[i] = cert.Raw
9994
}
100-
if err := verifyPeerCertificate(rawCerts, tlsConfig.RootCAs, tlsConfig.ServerName); err != nil {
95+
if err := verifyPeerCertificate(rawCerts, rootCAs, f.Domain); err != nil {
10196
rawConn.Close()
10297
return dialResult{nil, false, err}
10398
}

domainfront.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"net/http"
1414
"os"
1515
"path/filepath"
16-
"strings"
1716
"sync"
1817
"sync/atomic"
1918
"time"
@@ -60,6 +59,7 @@ type Client struct {
6059
httpClient *http.Client
6160

6261
crawlerConcurrency int
62+
readyQueueSize int
6363
wg sync.WaitGroup
6464
}
6565

@@ -79,6 +79,16 @@ func WithClientHelloID(id tls.ClientHelloID) Option {
7979
}
8080
func WithCrawlerConcurrency(n int) Option { return func(c *Client) { c.crawlerConcurrency = n } }
8181

82+
// WithReadyQueueSize sets the capacity of the ready-fronts channel.
83+
// Smaller values save memory on constrained devices. Default is 500.
84+
func WithReadyQueueSize(n int) Option { return func(c *Client) { c.readyQueueSize = n } }
85+
86+
// WithCacheSaveInterval sets how often dirty cache state is flushed to disk.
87+
// Longer intervals reduce I/O on flash storage (e.g. Android). Default is 5s.
88+
func WithCacheSaveInterval(d time.Duration) Option {
89+
return func(c *Client) { c.cacheSaveInterval = d }
90+
}
91+
8292
// WithCacheFile is a convenience option that sets a FileCache at the given path.
8393
func WithCacheFile(path string) Option {
8494
return func(c *Client) { c.cache = &FileCache{Path: path} }
@@ -97,7 +107,7 @@ func New(ctx context.Context, config *Config, options ...Option) (*Client, error
97107
ctx: innerCtx,
98108
cancel: cancel,
99109
log: slog.Default(),
100-
pool: newFrontPool(),
110+
pool: nil, // created after options are applied
101111
providers: make(map[string]*Provider),
102112
dialer: NetDialer{},
103113
clientHelloID: tls.HelloChrome_131,
@@ -121,6 +131,9 @@ func New(ctx context.Context, config *Config, options ...Option) (*Client, error
121131
c.crawlerConcurrency = 1
122132
}
123133

134+
// Create pool after options are applied so readyQueueSize can be set
135+
c.pool = newFrontPool(c.readyQueueSize)
136+
124137
if err := c.applyConfig(config); err != nil {
125138
cancel()
126139
return nil, fmt.Errorf("invalid config: %w", err)
@@ -230,15 +243,26 @@ func (c *Client) notifyCacheDirty() {
230243
func (c *Client) crawler() {
231244
defer c.wg.Done()
232245

246+
// Use a reusable timer instead of time.After to avoid leaking timers.
247+
// time.After creates a new timer each iteration that isn't GC'd until it
248+
// fires — on memory-constrained devices this creates mounting GC pressure.
249+
// Initialize stopped and drain to avoid an immediate spurious wake.
250+
timer := time.NewTimer(0)
251+
if !timer.Stop() {
252+
<-timer.C
253+
}
254+
defer timer.Stop()
255+
233256
for {
234257
if c.pool.readyCount() < 2 {
235258
c.crawlAllFronts()
236259
}
237260

261+
timer.Reset(time.Duration(6+rand.IntN(7)) * time.Second)
238262
select {
239263
case <-c.ctx.Done():
240264
return
241-
case <-time.After(time.Duration(6+rand.IntN(7)) * time.Second):
265+
case <-timer.C:
242266
}
243267
}
244268
}
@@ -300,16 +324,19 @@ func (c *Client) vetFront(f *front) bool {
300324
return c.verifyWithPost(result.conn, provider.TestURL)
301325
}
302326

327+
// vetBody is reused across vet requests to avoid per-call allocation.
328+
var vetBody = []byte("a")
329+
303330
func (c *Client) verifyWithPost(conn net.Conn, testURL string) bool {
304331
tr := newConnTransport(conn, true)
305-
client := &http.Client{Transport: tr}
306-
req, err := http.NewRequest(http.MethodPost, testURL, strings.NewReader("a"))
332+
req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(vetBody))
307333
if err != nil {
308334
c.log.Debug("Error creating vet request", "error", err)
309335
return false
310336
}
337+
req.URL.Scheme = "http" // TLS already established on conn
311338
req.Header.Set("Content-Type", "application/json")
312-
resp, err := client.Do(req)
339+
resp, err := tr.RoundTrip(req)
313340
if err != nil {
314341
c.log.Debug("Error vetting front", "error", err, "url", testURL)
315342
return false
@@ -328,16 +355,20 @@ func (c *Client) verifyWithPost(conn net.Conn, testURL string) bool {
328355
func (c *Client) cacheSaver() {
329356
defer c.wg.Done()
330357

358+
timer := time.NewTimer(c.cacheSaveInterval)
359+
defer timer.Stop()
360+
331361
for {
332362
select {
333363
case <-c.ctx.Done():
334364
return
335-
case <-time.After(c.cacheSaveInterval):
365+
case <-timer.C:
336366
select {
337367
case <-c.cacheDirty:
338368
c.saveCache()
339369
default:
340370
}
371+
timer.Reset(c.cacheSaveInterval)
341372
}
342373
}
343374
}

0 commit comments

Comments
 (0)