mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A socket op's handshake is decided before its handler runs, so a handler with effects runs only for a connection it will get: 426 for another protocol version, 400 without a key, and 403 for a browser on an origin the op does not admit — its own, plus zip.Origins(...). The origin policy is the op's, not the answer's, so Socket carries only Serve, Subprotocols and Compression, and wsx's own refusals are back to the library's. The in-process CLI refuses a socket op before invoking it. An op that answers JSON or bytes keeps JSON's media for its JSON when Produces names the bytes'. 204, 304 and a redirecting op's 3xx carry no body in the document; any other declared status, 3xx included, carries the Out and the doc comment's example, as before. An MCP read of a Reader is bounded in time as a stream is, and the reader closed. TestConnMemory counts the goroutines holding connections added, as it counts the heap they added, so goroutines other tests leave winding down are not charged to the server.
171 lines
4.7 KiB
Go
171 lines
4.7 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
// Per-connection memory profile for the zip framework itself —
|
|
// canonical regression test cited by SCALE_STANDARD.md §3 and §8.
|
|
// Every Hanzo Go service that exposes an HTTP listener copies this
|
|
// file into its own repo and tunes the route registration to the
|
|
// service shape; the budget assertion stays the same.
|
|
//
|
|
// Budget (verified on Apple M1 Max / Go 1.26.3 / Fiber v3 v3.2.0):
|
|
// per_conn_heap <= 12 KiB (target: 8 KiB)
|
|
// goroutines_per_conn == 1.00 (tolerance ±0.05)
|
|
//
|
|
// Run with:
|
|
// go test -mod=mod -run=TestConnMemory -v -conn-count=10000
|
|
|
|
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
var connCount = flag.Int("conn-count", 1000, "concurrent connections to hold")
|
|
|
|
// Budget assertions, expressed in bytes / ratio. SCALE_STANDARD.md §8
|
|
// pins these — do not loosen them without updating the doc.
|
|
const (
|
|
maxPerConnHeapBytes = 12 * 1024 // 12 KiB ceiling
|
|
goroutinesPerConnLow = 0.95
|
|
goroutinesPerConnHigh = 1.05
|
|
settleBeforeBaselineMs = 200
|
|
maxWaitForAcceptSeconds = 30
|
|
)
|
|
|
|
func TestConnMemory(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping memory profile in -short mode")
|
|
}
|
|
n := *connCount
|
|
|
|
app := zip.New(zip.Config{
|
|
Logger: luxlog.New("test", "zip-conn-memory"),
|
|
DisableStartupMessage: true,
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var holding atomic.Int64
|
|
app.Raw("GET", "/hold", func(c *zip.Ctx) error {
|
|
holding.Add(1)
|
|
defer holding.Add(-1)
|
|
<-ctx.Done()
|
|
return nil
|
|
})
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
go func() { _ = app.Fiber().Listener(ln) }()
|
|
defer func() { _ = app.Shutdown() }()
|
|
|
|
time.Sleep(settleBeforeBaselineMs * time.Millisecond)
|
|
var baseline, peak runtime.MemStats
|
|
runtime.GC()
|
|
runtime.ReadMemStats(&baseline)
|
|
// The goroutines a connection costs are the ones holding them added, as the
|
|
// heap a connection costs is the heap they added: what earlier tests in the
|
|
// process left winding down is not this server's.
|
|
before := runtime.NumGoroutine()
|
|
|
|
req := []byte("GET /hold HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
conns := make([]net.Conn, 0, n)
|
|
var connsMu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
c, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if _, err := c.Write(req); err != nil {
|
|
_ = c.Close()
|
|
return
|
|
}
|
|
connsMu.Lock()
|
|
conns = append(conns, c)
|
|
connsMu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
defer func() {
|
|
connsMu.Lock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
connsMu.Unlock()
|
|
}()
|
|
|
|
deadline := time.Now().Add(maxWaitForAcceptSeconds * time.Second)
|
|
for holding.Load() < int64(n) && time.Now().Before(deadline) {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if got := holding.Load(); got < int64(n) {
|
|
t.Logf("only %d/%d conns accepted before deadline", got, n)
|
|
n = int(got)
|
|
}
|
|
if n == 0 {
|
|
t.Fatal("no conns accepted — listener never came up")
|
|
}
|
|
|
|
runtime.GC()
|
|
runtime.ReadMemStats(&peak)
|
|
|
|
delta := int64(peak.HeapAlloc) - int64(baseline.HeapAlloc)
|
|
perConn := float64(delta) / float64(n)
|
|
totalGoroutines := runtime.NumGoroutine()
|
|
goroutinesPerConn := float64(totalGoroutines-before) / float64(n)
|
|
|
|
fmt.Printf("\n=== Per-connection memory profile (zip) ===\n")
|
|
fmt.Printf("conns held : %d\n", n)
|
|
fmt.Printf("baseline heap : %s\n", humanBytes(int64(baseline.HeapAlloc)))
|
|
fmt.Printf("peak heap : %s\n", humanBytes(int64(peak.HeapAlloc)))
|
|
fmt.Printf("delta : %s\n", humanBytes(delta))
|
|
fmt.Printf("per-conn heap : %.0f B (%.2f KiB)\n", perConn, perConn/1024)
|
|
fmt.Printf("goroutines total : %d\n", totalGoroutines)
|
|
fmt.Printf("goroutines / conn: %.2f\n", goroutinesPerConn)
|
|
fmt.Printf("==================================================\n\n")
|
|
|
|
if perConn > maxPerConnHeapBytes {
|
|
t.Errorf("per-conn heap %.0f B exceeds budget %d B (see SCALE_STANDARD.md §3)",
|
|
perConn, maxPerConnHeapBytes)
|
|
}
|
|
if goroutinesPerConn < goroutinesPerConnLow || goroutinesPerConn > goroutinesPerConnHigh {
|
|
t.Errorf("goroutines/conn %.2f outside [%.2f, %.2f] (see SCALE_STANDARD.md §3)",
|
|
goroutinesPerConn, goroutinesPerConnLow, goroutinesPerConnHigh)
|
|
}
|
|
|
|
cancel()
|
|
wg.Wait()
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < 0 {
|
|
return "-" + humanBytes(-n)
|
|
}
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for x := n / unit; x >= unit; x /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.2f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|