Skip to content

Commit d2cb859

Browse files
committed
reduce memory
1 parent 64e6442 commit d2cb859

4 files changed

Lines changed: 234 additions & 24 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,8 @@ OPTIMIZATIONS:
265265
-retries int number of retries
266266
-timeout int timeout in seconds (default 10)
267267
-delay value duration between each http request (eg: 200ms, 1s) (default -1ns)
268-
-rsts, -response-size-to-save int max response size to save in bytes (default 512000000)
269-
-rstr, -response-size-to-read int max response size to read in bytes (default 512000000)
268+
-rsts, -response-size-to-save int max response size to save in bytes (default 50000000)
269+
-rstr, -response-size-to-read int max response size to read in bytes (default 50000000)
270270

271271
CLOUD:
272272
-auth configure projectdiscovery cloud (pdcp) api key (default true)

common/httpx/httpx.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package httpx
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/tls"
67
"fmt"
@@ -291,21 +292,19 @@ get_response:
291292
return nil, closeErr
292293
}
293294

294-
// Todo: replace with https://github.com/projectdiscovery/utils/issues/110
295-
resp.RawData = make([]byte, len(respbody))
296-
copy(resp.RawData, respbody)
295+
// Keep a reference to the undecoded body. DecodeData returns the same slice
296+
// when no transcoding is needed (the common case), so RawData and Data end up
297+
// sharing the same backing array and we avoid an extra full-body copy. When
298+
// DecodeData transcodes it returns a fresh slice, so RawData still holds the
299+
// original undecoded bytes. Both fields are read-only afterwards, so sharing
300+
// the backing array is safe.
301+
rawbody := respbody
297302

298303
respbody, err = DecodeData(respbody, httpresp.Header)
299304
if err != nil && !shouldIgnoreBodyErrors {
300305
return nil, err
301306
}
302-
303-
respbodystr := string(respbody)
304-
305-
// check if we need to strip html
306-
if h.Options.VHostStripHTML {
307-
respbodystr = h.htmlPolicy.Sanitize(respbodystr)
308-
}
307+
resp.RawData = rawbody
309308

310309
// if content length is not defined
311310
if resp.ContentLength <= 0 {
@@ -326,11 +325,23 @@ get_response:
326325

327326
// fill metrics
328327
resp.StatusCode = httpresp.StatusCode
329-
if respbodystr != "" {
330-
// number of words
331-
resp.Words = len(strings.Split(respbodystr, " "))
332-
// number of lines
333-
resp.Lines = len(strings.Split(strings.TrimSpace(respbodystr), "\n"))
328+
329+
// Word/line counts are computed directly over the body bytes to avoid
330+
// materializing an extra full-body string copy (and the slice produced by
331+
// strings.Split) on the hot path. When HTML stripping is enabled the
332+
// sanitized string is required, so counts are derived from it to preserve the
333+
// previous behavior.
334+
if h.Options.VHostStripHTML {
335+
respbodystr := h.htmlPolicy.Sanitize(string(respbody))
336+
if respbodystr != "" {
337+
resp.Words = len(strings.Split(respbodystr, " "))
338+
resp.Lines = len(strings.Split(strings.TrimSpace(respbodystr), "\n"))
339+
}
340+
} else if len(respbody) > 0 {
341+
// equivalent to len(strings.Split(string(respbody), " ")) and
342+
// len(strings.Split(strings.TrimSpace(string(respbody)), "\n"))
343+
resp.Words = bytes.Count(respbody, []byte{' '}) + 1
344+
resp.Lines = bytes.Count(bytes.TrimSpace(respbody), []byte{'\n'}) + 1
334345
}
335346

336347
if !h.Options.Unsafe && h.Options.TLSGrab {

common/httpx/option.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,22 @@ import (
1010
"github.com/projectdiscovery/networkpolicy"
1111
)
1212

13-
// DefaultMaxResponseBodySize is the default maximum response body size
14-
var DefaultMaxResponseBodySize int64
15-
16-
func init() {
17-
maxResponseBodySize, _ := humanize.ParseBytes("512Mb")
18-
DefaultMaxResponseBodySize = int64(maxResponseBodySize)
19-
}
13+
// DefaultMaxResponseBodySize is the default maximum response body size that httpx
14+
// reads into memory for processing (and, via the runner, the default cap for
15+
// responses stored to disk with -sr). It is intentionally bounded: the body is
16+
// held in memory and the footprint scales with the number of concurrent threads,
17+
// so a very large cap can lead to excessive memory usage / OOM on large
18+
// responses. Normal web pages are far smaller than this; use -rstr / -rsts to
19+
// read or store larger responses when needed.
20+
//
21+
// NOTE: this is a var initializer (not an init() function) on purpose. init()
22+
// functions run after all package-level variable initializers, so computing the
23+
// value in init() left DefaultOptions (which references it below) observing a
24+
// zero value during package initialization.
25+
var DefaultMaxResponseBodySize = func() int64 {
26+
maxResponseBodySize, _ := humanize.ParseBytes("50mb")
27+
return int64(maxResponseBodySize)
28+
}()
2029

2130
// Options contains configuration options for the client
2231
type Options struct {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package httpx
2+
3+
import (
4+
"bytes"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/projectdiscovery/retryablehttp-go"
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/text/encoding/simplifiedchinese"
14+
"golang.org/x/text/transform"
15+
)
16+
17+
// newLocalHTTPX builds an HTTPX instance suitable for hitting a local test
18+
// server only (no external network, CDN checks disabled).
19+
func newLocalHTTPX(t *testing.T) *HTTPX {
20+
t.Helper()
21+
options := DefaultOptions
22+
options.CdnCheck = "false"
23+
options.Timeout = 5 * time.Second
24+
options.RetryMax = 0
25+
// NB: relies on DefaultOptions.MaxResponseBodySizeToRead being non-zero
26+
// (see TestDefaultOptionsHasNonZeroReadSize) so the body is actually read.
27+
28+
ht, err := New(&options)
29+
require.NoError(t, err)
30+
return ht
31+
}
32+
33+
// doLocal issues a GET against a local httptest server and returns the parsed
34+
// httpx Response.
35+
func doLocal(t *testing.T, ht *HTTPX, url string) *Response {
36+
t.Helper()
37+
req, err := retryablehttp.NewRequest(http.MethodGet, url, nil)
38+
require.NoError(t, err)
39+
resp, err := ht.Do(req, UnsafeOptions{})
40+
require.NoError(t, err)
41+
return resp
42+
}
43+
44+
// legacyWordsLines reproduces the exact word/line computation that existed
45+
// before the refactor, so we can assert the new byte-based path is equivalent.
46+
func legacyWordsLines(body []byte) (words, lines int) {
47+
s := string(body)
48+
if s != "" {
49+
words = len(strings.Split(s, " "))
50+
lines = len(strings.Split(strings.TrimSpace(s), "\n"))
51+
}
52+
return
53+
}
54+
55+
// TestDefaultOptionsHasNonZeroReadSize guards against the package var-init
56+
// ordering regression where DefaultOptions was initialized before
57+
// DefaultMaxResponseBodySize, leaving MaxResponseBodySizeToRead at 0 (which made
58+
// LimitReader read zero bytes and produced empty bodies for library users).
59+
func TestDefaultOptionsHasNonZeroReadSize(t *testing.T) {
60+
require.NotZero(t, DefaultMaxResponseBodySize)
61+
require.Equal(t, DefaultMaxResponseBodySize, DefaultOptions.MaxResponseBodySizeToRead)
62+
}
63+
64+
func TestDoBodyNoDecodePreservesRawAndData(t *testing.T) {
65+
body := []byte("hello world\nsecond line\n")
66+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+
w.Header().Set("Content-Type", "text/html")
68+
_, _ = w.Write(body)
69+
}))
70+
defer ts.Close()
71+
72+
resp := doLocal(t, newLocalHTTPX(t), ts.URL)
73+
74+
require.Equal(t, body, resp.Data, "decoded data must equal body")
75+
require.Equal(t, body, resp.RawData, "raw data must equal undecoded body")
76+
77+
wantWords, wantLines := legacyWordsLines(body)
78+
require.Equal(t, wantWords, resp.Words)
79+
require.Equal(t, wantLines, resp.Lines)
80+
}
81+
82+
func TestDoBodyEmpty(t *testing.T) {
83+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
w.Header().Set("Content-Type", "text/html")
85+
}))
86+
defer ts.Close()
87+
88+
resp := doLocal(t, newLocalHTTPX(t), ts.URL)
89+
require.Empty(t, resp.Data)
90+
require.Empty(t, resp.RawData)
91+
require.Equal(t, 0, resp.Words)
92+
require.Equal(t, 0, resp.Lines)
93+
}
94+
95+
// TestDoBodyGBKDecodeKeepsRawUndecoded ensures that when DecodeData actually
96+
// transcodes the body, RawData still holds the original (undecoded) bytes while
97+
// Data holds the decoded UTF-8 bytes.
98+
func TestDoBodyGBKDecodeKeepsRawUndecoded(t *testing.T) {
99+
utf8Body := "<html><head></head><body>你好世界 测试</body></html>"
100+
gbkBody, _, err := transform.Bytes(simplifiedchinese.GBK.NewEncoder(), []byte(utf8Body))
101+
require.NoError(t, err)
102+
require.NotEqual(t, []byte(utf8Body), gbkBody, "precondition: gbk bytes differ from utf8")
103+
104+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105+
w.Header().Set("Content-Type", "text/html; charset=gbk")
106+
_, _ = w.Write(gbkBody)
107+
}))
108+
defer ts.Close()
109+
110+
resp := doLocal(t, newLocalHTTPX(t), ts.URL)
111+
112+
require.Equal(t, gbkBody, resp.RawData, "RawData must hold the original undecoded bytes")
113+
require.Equal(t, []byte(utf8Body), resp.Data, "Data must hold the decoded UTF-8 bytes")
114+
}
115+
116+
// TestDoBodyNoDecodeSharesBacking documents the memory optimization: on the
117+
// no-decode hot path RawData and Data share the same backing array (no extra
118+
// full-body copy is made).
119+
func TestDoBodyNoDecodeSharesBacking(t *testing.T) {
120+
body := []byte("shared backing array body")
121+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+
w.Header().Set("Content-Type", "text/plain")
123+
_, _ = w.Write(body)
124+
}))
125+
defer ts.Close()
126+
127+
resp := doLocal(t, newLocalHTTPX(t), ts.URL)
128+
129+
require.NotEmpty(t, resp.Data)
130+
require.NotEmpty(t, resp.RawData)
131+
require.Equal(t, resp.Data, resp.RawData)
132+
require.Same(t, &resp.Data[0], &resp.RawData[0],
133+
"RawData and Data should share the backing array on the no-decode path")
134+
}
135+
136+
// TestWordsLinesEquivalence is the core guard for the refactor: the byte-based
137+
// counting used on the hot path must be identical to the previous
138+
// strings.Split-based counting for a wide range of inputs and edge cases.
139+
func TestWordsLinesEquivalence(t *testing.T) {
140+
cases := []string{
141+
"",
142+
"a",
143+
"a b c",
144+
" ", // only spaces
145+
"a b", // consecutive spaces
146+
"line1\nline2\nline3", // multiple lines
147+
"\n\n\n", // only newlines
148+
" leading and trailing ", // surrounding whitespace
149+
"\n mixed \t whitespace \n", // tabs/newlines around
150+
"trailing newline\n",
151+
"word",
152+
"tab\tseparated values",
153+
"unicode \u00a0 nbsp space",
154+
"emoji 😀 and spaces ",
155+
}
156+
157+
for _, c := range cases {
158+
body := []byte(c)
159+
wantWords, wantLines := legacyWordsLines(body)
160+
161+
var gotWords, gotLines int
162+
if len(body) > 0 {
163+
gotWords = bytes.Count(body, []byte{' '}) + 1
164+
gotLines = bytes.Count(bytes.TrimSpace(body), []byte{'\n'}) + 1
165+
}
166+
167+
require.Equalf(t, wantWords, gotWords, "words mismatch for %q", c)
168+
require.Equalf(t, wantLines, gotLines, "lines mismatch for %q", c)
169+
}
170+
}
171+
172+
// TestBodyMetricsCountingDoesNotAllocate locks in the optimization: the
173+
// byte-based word/line counting used on the hot path must not allocate (the
174+
// previous string(respbody) + strings.Split approach allocated O(len(body))).
175+
// If someone reintroduces a full-body string copy or Split-based counting, this
176+
// test fails.
177+
func TestBodyMetricsCountingDoesNotAllocate(t *testing.T) {
178+
body := bytes.Repeat([]byte("lorem ipsum dolor sit amet\n"), 40000) // ~1MB
179+
var words, lines int
180+
181+
allocs := testing.AllocsPerRun(50, func() {
182+
// identical expressions to the hot path in Do()
183+
words = bytes.Count(body, []byte{' '}) + 1
184+
lines = bytes.Count(bytes.TrimSpace(body), []byte{'\n'}) + 1
185+
})
186+
187+
require.NotZero(t, words)
188+
require.NotZero(t, lines)
189+
require.Zerof(t, allocs, "word/line counting must not allocate, got %v allocs/op", allocs)
190+
}

0 commit comments

Comments
 (0)