mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
perf(hooks): replace net/http with minimal HTTP/1.0 client to shrink hook binaries
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Experiment journal
|
||||
|
||||
## Campaign B: binary size
|
||||
|
||||
Metric: sum of bytes of worker, mcp and 6 hook binaries (release flags, `-s -w -trimpath`, `-tags fts5`). Lower is better.
|
||||
Threshold: at least 3% drop, tests green. Stop: 5 consecutive no-improve trials or 15 trials.
|
||||
Mutate: Go code and build flags. Frozen: public API, DB schema, plugin manifest.
|
||||
|
||||
| id | mutation | total bytes | delta vs base | kept? |
|
||||
|----|----------|-------------|---------------|-------|
|
||||
| 01 | baseline | 102698416 | - | base |
|
||||
| 02 | pkg/hooks: raw HTTP/1.0 client replaces net/http | 87603952 | -14.7% | KEEP |
|
||||
@@ -0,0 +1,81 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxResponseBytes caps a worker response so a misbehaving peer cannot exhaust memory.
|
||||
const maxResponseBytes = 32 << 20
|
||||
|
||||
// Minimal HTTP/1.0 client over a raw TCP socket. Linking net/http adds ~3 MB to
|
||||
// every hook binary; hooks only speak plain JSON to the local worker. HTTP/1.0
|
||||
// makes the server skip chunked encoding and close the connection, so the body
|
||||
// is simply everything after the header block.
|
||||
type httpResponse struct {
|
||||
Status int
|
||||
StatusText string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func httpDo(ctx context.Context, timeout time.Duration, method string, port int, path string, body []byte) (*httpResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
}
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
|
||||
var req bytes.Buffer
|
||||
fmt.Fprintf(&req, "%s %s HTTP/1.0\r\nHost: %s\r\n", method, path, addr)
|
||||
if body != nil {
|
||||
fmt.Fprintf(&req, "Content-Type: application/json\r\nContent-Length: %d\r\n", len(body))
|
||||
}
|
||||
req.WriteString("\r\n")
|
||||
req.Write(body)
|
||||
if _, err := conn.Write(req.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(bufio.NewReader(conn), maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseHTTPResponse(raw)
|
||||
}
|
||||
|
||||
func parseHTTPResponse(raw []byte) (*httpResponse, error) {
|
||||
head, body, found := bytes.Cut(raw, []byte("\r\n\r\n"))
|
||||
if !found {
|
||||
return nil, errors.New("malformed http response")
|
||||
}
|
||||
statusLine, _, _ := strings.Cut(string(head), "\r\n")
|
||||
_, rest, ok := strings.Cut(statusLine, " ")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("malformed status line %q", statusLine)
|
||||
}
|
||||
codeStr, _, _ := strings.Cut(rest, " ")
|
||||
code, err := strconv.Atoi(codeStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed status line %q", statusLine)
|
||||
}
|
||||
return &httpResponse{Status: code, StatusText: rest, Body: body}, nil
|
||||
}
|
||||
+36
-134
@@ -2,12 +2,10 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -28,6 +26,9 @@ const (
|
||||
// HealthCheckTimeout is the timeout for health checks.
|
||||
HealthCheckTimeout = 2 * time.Second
|
||||
|
||||
// hookTimeout bounds a single hook->worker request.
|
||||
hookTimeout = 10 * time.Second
|
||||
|
||||
// StartupTimeout is the timeout for worker startup.
|
||||
StartupTimeout = 10 * time.Second
|
||||
|
||||
@@ -52,26 +53,6 @@ var (
|
||||
// circuitBreakerMu protects lastStartupFailure.
|
||||
circuitBreakerMu sync.Mutex
|
||||
lastStartupFailure time.Time
|
||||
|
||||
// hookClient is a shared HTTP client for hook->worker requests.
|
||||
// DisableKeepAlives prevents TIME_WAIT connection leaks since each hook
|
||||
// is a separate OS process that exits quickly.
|
||||
hookClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
MaxIdleConns: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// healthClient is a shared HTTP client for health/version checks.
|
||||
healthClient = &http.Client{
|
||||
Timeout: HealthCheckTimeout,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
MaxIdleConns: 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// IsWorkerAvailable performs a fast check without network calls.
|
||||
@@ -110,22 +91,21 @@ func GetWorkerPort() int {
|
||||
// Parses the JSON health response to check the "ready" field when available.
|
||||
// Falls back to HTTP status code check for backwards compatibility.
|
||||
func IsWorkerRunning(port int) bool {
|
||||
resp, err := healthClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
||||
resp, err := httpDo(context.Background(), HealthCheckTimeout, "GET", port, "/api/health", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Try to parse JSON response for structured health check
|
||||
var health struct {
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&health); err == nil {
|
||||
if err := json.Unmarshal(resp.Body, &health); err == nil {
|
||||
return health.Ready
|
||||
}
|
||||
|
||||
// Fallback: treat HTTP 200 as healthy (backwards compatibility)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
return resp.Status == 200
|
||||
}
|
||||
|
||||
// workerCachePath returns the path to the worker cache file.
|
||||
@@ -396,18 +376,17 @@ func updateCacheFromPort(port int) {
|
||||
|
||||
// GetWorkerVersion gets the version of the running worker.
|
||||
func GetWorkerVersion(port int) string {
|
||||
resp, err := healthClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/version", port))
|
||||
resp, err := httpDo(context.Background(), HealthCheckTimeout, "GET", port, "/api/version", nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.Status != 200 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -510,58 +489,46 @@ func findWorkerBinary() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// POST sends a POST request to the worker.
|
||||
func POST(port int, path string, body interface{}) (map[string]interface{}, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
// request performs one worker call and returns the decoded JSON object.
|
||||
// A non-JSON body yields (nil, nil) when lenient, else the decode error.
|
||||
func request(ctx context.Context, method string, port int, path string, body interface{}, lenient bool) (map[string]interface{}, error) {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if payload, err = json.Marshal(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := httpDo(ctx, hookTimeout, method, port, path, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := hookClient.Post(
|
||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||
"application/json",
|
||||
bytes.NewReader(jsonBody),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
if resp.Status >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.StatusText)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
if lenient {
|
||||
// Not all endpoints return JSON
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// POST sends a POST request to the worker.
|
||||
func POST(port int, path string, body interface{}) (map[string]interface{}, error) {
|
||||
return request(context.Background(), "POST", port, path, body, true)
|
||||
}
|
||||
|
||||
// POSTWithContext sends a POST request using the provided context.
|
||||
// Used for fire-and-forget calls where we want to control the timeout externally.
|
||||
func POSTWithContext(ctx context.Context, port int, path string, body interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
_, err := request(ctx, "POST", port, path, body, true)
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||
bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := hookClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// POSTWithContextResult sends a POST request using the provided context and
|
||||
@@ -570,56 +537,12 @@ func POSTWithContext(ctx context.Context, port int, path string, body interface{
|
||||
// aborts at the hook deadline instead of blocking for the full client timeout.
|
||||
// A non-JSON body is returned as (nil, nil), matching POST's behavior.
|
||||
func POSTWithContextResult(ctx context.Context, port int, path string, body interface{}) (map[string]interface{}, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||
bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := hookClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
// Not all endpoints return JSON
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return request(ctx, "POST", port, path, body, true)
|
||||
}
|
||||
|
||||
// GET sends a GET request to the worker.
|
||||
func GET(port int, path string) (map[string]interface{}, error) {
|
||||
resp, err := hookClient.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return request(context.Background(), "GET", port, path, nil, false)
|
||||
}
|
||||
|
||||
// GETWithContext sends a GET request using the provided context and decodes the
|
||||
@@ -627,28 +550,7 @@ func GET(port int, path string) (map[string]interface{}, error) {
|
||||
// Used on the prompt critical path so a wedged worker aborts at the hook
|
||||
// deadline instead of blocking for the full client timeout.
|
||||
func GETWithContext(ctx context.Context, port int, path string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := hookClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return request(ctx, "GET", port, path, nil, false)
|
||||
}
|
||||
|
||||
// versionsCompatible checks if two versions are compatible for dev builds.
|
||||
|
||||
+98
-15
@@ -5,8 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1481,22 +1485,101 @@ func TestSleepCtx_Cancelled(t *testing.T) {
|
||||
assert.Less(t, elapsed, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
// TestHookClients_DisableKeepAlives asserts shared clients disable keep-alives
|
||||
// to prevent TIME_WAIT connection leaks in short-lived hook processes.
|
||||
func TestHookClients_DisableKeepAlives(t *testing.T) {
|
||||
hTransport, ok := hookClient.Transport.(*http.Transport)
|
||||
require.True(t, ok, "hookClient.Transport should be *http.Transport")
|
||||
assert.True(t, hTransport.DisableKeepAlives, "hookClient must disable keep-alives")
|
||||
assert.Equal(t, 1, hTransport.MaxIdleConns)
|
||||
// TestHTTPDo_Behaviour covers the raw HTTP/1.0 client against a real server.
|
||||
func TestHTTPDo_Behaviour(t *testing.T) {
|
||||
big := strings.Repeat("x", 200_000)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/echo":
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
_, _ = w.Write(b)
|
||||
case "/big":
|
||||
_, _ = w.Write([]byte(`{"v":"` + big + `"}`))
|
||||
case "/missing":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case "/empty":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
port, err := strconv.Atoi(srv.URL[strings.LastIndex(srv.URL, ":")+1:])
|
||||
require.NoError(t, err)
|
||||
|
||||
hcTransport, ok := healthClient.Transport.(*http.Transport)
|
||||
require.True(t, ok, "healthClient.Transport should be *http.Transport")
|
||||
assert.True(t, hcTransport.DisableKeepAlives, "healthClient must disable keep-alives")
|
||||
assert.Equal(t, 1, hcTransport.MaxIdleConns)
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body []byte
|
||||
wantStatus int
|
||||
wantLen int
|
||||
}{
|
||||
{"post echoes body", "POST", "/echo", []byte(`{"a":1}`), 200, 7},
|
||||
{"large body over server buffer", "GET", "/big", nil, 200, len(big) + 8},
|
||||
{"not found", "GET", "/missing", nil, 404, 0},
|
||||
{"no content", "GET", "/empty", nil, 204, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := httpDo(context.Background(), time.Second, tt.method, port, tt.path, tt.body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantStatus, resp.Status)
|
||||
assert.Len(t, resp.Body, tt.wantLen)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookClient_Timeout verifies hookClient timeout is set.
|
||||
func TestHookClient_Timeout(t *testing.T) {
|
||||
assert.Equal(t, 10*time.Second, hookClient.Timeout)
|
||||
assert.Equal(t, HealthCheckTimeout, healthClient.Timeout)
|
||||
// TestHTTPDo_Failures covers connection, timeout, cancel and malformed replies.
|
||||
func TestHTTPDo_Failures(t *testing.T) {
|
||||
t.Run("connection refused", func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, l.Close())
|
||||
_, err = httpDo(context.Background(), time.Second, "GET", port, "/", nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("wedged server times out", func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = l.Close() }()
|
||||
go func() {
|
||||
c, err := l.Accept()
|
||||
if err == nil {
|
||||
defer func() { _ = c.Close() }()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
start := time.Now()
|
||||
_, err = httpDo(context.Background(), 200*time.Millisecond, "GET", l.Addr().(*net.TCPAddr).Port, "/", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
})
|
||||
|
||||
t.Run("cancelled context aborts", func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = l.Close() }()
|
||||
go func() {
|
||||
c, err := l.Accept()
|
||||
if err == nil {
|
||||
defer func() { _ = c.Close() }()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
_, err = httpDo(ctx, 5*time.Second, "GET", l.Addr().(*net.TCPAddr).Port, "/", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
})
|
||||
|
||||
t.Run("malformed response", func(t *testing.T) {
|
||||
for _, raw := range []string{"", "garbage", "HTTP/1.0\r\n\r\n", "HTTP/1.0 abc OK\r\n\r\n"} {
|
||||
_, err := parseHTTPResponse([]byte(raw))
|
||||
assert.Error(t, err, "input %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user