on main help sign in
Merge pull request #32 from charmbracelet/perf-http-clients

perf: optimize HTTP client pooling and binary file detection

commitacb1ad12429985f6aa41af537f29d2b6fb77da9b authorRaphael Amorim <rapha850@gmail.com> dateMon 16 Jun 2025, 19:10 (1 year ago) parentec1b652b700ec3
3 files changed+72−12
internal/llm/tools/fetch.go+12−6 internal/llm/tools/grep.go+47 internal/llm/tools/sourcegraph.go+13−6
internal/llm/tools/fetch.go+12−6
@@ -69,6 +69,11 @@ func NewFetchTool(permissions permission.Service) BaseTool {
6969 return &fetchTool{
7070 client: &http.Client{
7171 Timeout: 30 * time.Second,
72+ Transport: &http.Transport{
73+ MaxIdleConns: 100,
74+ MaxIdleConnsPerHost: 10,
75+ IdleConnTimeout: 90 * time.Second,
76+ },
7277 },
7378 permissions: permissions,
7479 }
@@ -136,25 +141,26 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
136141 return ToolResponse{}, permission.ErrorPermissionDenied
137142 }
138143
139− client := t.client
144+ // Handle timeout with context
145+ requestCtx := ctx
140146 if params.Timeout > 0 {
141147 maxTimeout := 120 // 2 minutes
142148 if params.Timeout > maxTimeout {
143149 params.Timeout = maxTimeout
144150 }
145− client = &http.Client{
146− Timeout: time.Duration(params.Timeout) * time.Second,
147− }
151+ var cancel context.CancelFunc
152+ requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
153+ defer cancel()
148154 }
149155
150− req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
156+ req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
151157 if err != nil {
152158 return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
153159 }
154160
155161 req.Header.Set("User-Agent", "crush/1.0")
156162
157− resp, err := client.Do(req)
163+ resp, err := t.client.Do(req)
158164 if err != nil {
159165 return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
160166 }
internal/llm/tools/grep.go+47
@@ -5,6 +5,7 @@ import (
55 "context"
66 "encoding/json"
77 "fmt"
8+ "io"
89 "os"
910 "os/exec"
1011 "path/filepath"
@@ -377,6 +378,11 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
377378}
378379
379380func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
381+ // Quick binary file detection
382+ if isBinaryFile(filePath) {
383+ return false, 0, "", nil
384+ }
385+
380386 file, err := os.Open(filePath)
381387 if err != nil {
382388 return false, 0, "", err
@@ -396,6 +402,47 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
396402 return false, 0, "", scanner.Err()
397403}
398404
405+var binaryExts = map[string]struct{}{
406+ ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
407+ ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
408+ ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
409+ ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
410+ ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
411+ ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
412+}
413+
414+// isBinaryFile performs a quick check to determine if a file is binary
415+func isBinaryFile(filePath string) bool {
416+ // Check file extension first (fastest)
417+ ext := strings.ToLower(filepath.Ext(filePath))
418+ if _, isBinary := binaryExts[ext]; isBinary {
419+ return true
420+ }
421+
422+ // Quick content check for files without clear extensions
423+ file, err := os.Open(filePath)
424+ if err != nil {
425+ return false // If we can't open it, let the caller handle the error
426+ }
427+ defer file.Close()
428+
429+ // Read first 512 bytes to check for null bytes
430+ buffer := make([]byte, 512)
431+ n, err := file.Read(buffer)
432+ if err != nil && err != io.EOF {
433+ return false
434+ }
435+
436+ // Check for null bytes (common in binary files)
437+ for i := range n {
438+ if buffer[i] == 0 {
439+ return true
440+ }
441+ }
442+
443+ return false
444+}
445+
399446func globToRegex(glob string) string {
400447 regexPattern := strings.ReplaceAll(glob, ".", "\\.")
401448 regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
internal/llm/tools/sourcegraph.go+13−6
@@ -129,6 +129,11 @@ func NewSourcegraphTool() BaseTool {
129129 return &sourcegraphTool{
130130 client: &http.Client{
131131 Timeout: 30 * time.Second,
132+ Transport: &http.Transport{
133+ MaxIdleConns: 100,
134+ MaxIdleConnsPerHost: 10,
135+ IdleConnTimeout: 90 * time.Second,
136+ },
132137 },
133138 }
134139}
@@ -178,15 +183,17 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
178183 if params.ContextWindow <= 0 {
179184 params.ContextWindow = 10 // Default context window
180185 }
181− client := t.client
186+
187+ // Handle timeout with context
188+ requestCtx := ctx
182189 if params.Timeout > 0 {
183190 maxTimeout := 120 // 2 minutes
184191 if params.Timeout > maxTimeout {
185192 params.Timeout = maxTimeout
186193 }
187− client = &http.Client{
188− Timeout: time.Duration(params.Timeout) * time.Second,
189− }
194+ var cancel context.CancelFunc
195+ requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
196+ defer cancel()
190197 }
191198
192199 type graphqlRequest struct {
@@ -208,7 +215,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
208215 graphqlQuery := string(graphqlQueryBytes)
209216
210217 req, err := http.NewRequestWithContext(
211− ctx,
218+ requestCtx,
212219 "POST",
213220 "https://sourcegraph.com/.api/graphql",
214221 bytes.NewBuffer([]byte(graphqlQuery)),
@@ -220,7 +227,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
220227 req.Header.Set("Content-Type", "application/json")
221228 req.Header.Set("User-Agent", "crush/1.0")
222229
223− resp, err := client.Do(req)
230+ resp, err := t.client.Do(req)
224231 if err != nil {
225232 return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
226233 }