mirror of
https://github.com/soypat/lneto.git
synced 2026-08-11 18:33:46 +00:00
d05cd14018
* httphi: RouterConfig refactor to enable DefaultRouterConfig * RequestHeader not case sensitive anymore * httphi: use DefaultRouterConfig in examples * httphi: remove gated stage complexity Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods * httphi: improve APIs * httphi: remove status
232 lines
8.5 KiB
Go
232 lines
8.5 KiB
Go
package httphi
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/soypat/lneto/http/httpraw"
|
|
)
|
|
|
|
// budgetMux exposes a settable path value count so budget tests can sweep it
|
|
// without registering patterns that bind that many wildcards.
|
|
type budgetMux struct {
|
|
MuxSlice
|
|
maxPathValues int
|
|
}
|
|
|
|
func (m *budgetMux) MaxPathValues() int { return m.maxPathValues }
|
|
|
|
func newBudgetMux(maxPathValues int) *budgetMux {
|
|
mux := &budgetMux{maxPathValues: maxPathValues}
|
|
mux.Handle("GET /", func(*Exchange) {})
|
|
return mux
|
|
}
|
|
|
|
// TestDefaultRouterConfigHonorsBudget sweeps budgets and path value counts and
|
|
// checks the returned configuration both fits its budget and configures a
|
|
// router. The floor is documented: below it the minimum viable exchange comes
|
|
// back instead, which is the only case allowed to exceed the budget.
|
|
func TestDefaultRouterConfigHonorsBudget(t *testing.T) {
|
|
minCfg := RouterConfig{
|
|
FixedNumGoroutines: 1,
|
|
RequestHeaderBufferSize: minRequestHeaderBuffer,
|
|
ResponseHeaderMinBufferSize: minResponseHeaderBuffer,
|
|
RequestNumHeaderKVCap: 1,
|
|
}
|
|
for _, numGoro := range []int{-1, 1, 4} {
|
|
for _, maxPathValues := range []int{0, 1, 4, 32} {
|
|
floor := minCfg.MemoryUsagePerConnection(maxPathValues)
|
|
mux := newBudgetMux(maxPathValues)
|
|
for _, budget := range []int{0, 1, 64, 256, 512, 1024, 4096, 65536, 1 << 20} {
|
|
cfg := DefaultRouterConfig(numGoro, budget, maxPathValues)
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("goro=%d pathvals=%d budget=%d: %s", numGoro, maxPathValues, budget, err)
|
|
}
|
|
got := cfg.MemoryUsagePerConnection(maxPathValues)
|
|
if got > budget && budget >= floor {
|
|
t.Errorf("goro=%d pathvals=%d budget=%d: uses %d bytes, over budget",
|
|
numGoro, maxPathValues, budget, got)
|
|
}
|
|
var router Router
|
|
if err := router.Configure(mux, cfg); err != nil {
|
|
t.Fatalf("goro=%d pathvals=%d budget=%d: %s", numGoro, maxPathValues, budget, err)
|
|
}
|
|
router.Shutdown()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDefaultRouterConfigSpendsBudget guards the other direction: a config that
|
|
// fits but leaves most of the budget unspent is as wrong as one that overruns,
|
|
// since the memory is reserved either way.
|
|
func TestDefaultRouterConfigSpendsBudget(t *testing.T) {
|
|
const maxPathValues = 2
|
|
for _, budget := range []int{1024, 2048, 4096, 16384} {
|
|
cfg := DefaultRouterConfig(4, budget, maxPathValues)
|
|
used := cfg.MemoryUsagePerConnection(maxPathValues)
|
|
if pct := used * 100 / budget; pct < 95 {
|
|
t.Errorf("budget=%d: spends only %d bytes (%d%%)", budget, used, pct)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExchangeMemoryTerms pins each term of MemoryUsagePerConnection to the
|
|
// allocation it stands for, so a layout change downstream fails here rather than
|
|
// silently letting a router overrun its budget.
|
|
func TestExchangeMemoryTerms(t *testing.T) {
|
|
const maxPathValues = 4
|
|
cfg := RouterConfig{
|
|
FixedNumGoroutines: 2,
|
|
RequestHeaderBufferSize: 512,
|
|
ResponseHeaderMinBufferSize: 128,
|
|
RequestNumHeaderKVCap: 16,
|
|
}
|
|
want := sizeofExchange + 512 + 128 + 16*httpraw.SizeKV + maxPathValues*sizeofPathValue + sizeofJob
|
|
if got := cfg.MemoryUsagePerConnection(maxPathValues); got != want {
|
|
t.Errorf("worker mode: got %d want %d", got, want)
|
|
}
|
|
// Unbounded mode has no job queue to reserve a slot in.
|
|
cfg.FixedNumGoroutines = -1
|
|
if got := cfg.MemoryUsagePerConnection(maxPathValues); got != want-sizeofJob {
|
|
t.Errorf("unbounded mode: got %d want %d", got, want-sizeofJob)
|
|
}
|
|
// A mux with no routes registered reports -1, which must not subtract memory.
|
|
if got := cfg.MemoryUsagePerConnection(-1); got != cfg.MemoryUsagePerConnection(0) {
|
|
t.Errorf("unregistered mux: got %d want %d", got, cfg.MemoryUsagePerConnection(0))
|
|
}
|
|
}
|
|
|
|
// TestRouterSharesExchangeStores checks the invariant the memory accounting
|
|
// rests on: every exchange's buffer and path values are windows into the two
|
|
// stores the router allocates, non-overlapping and exactly the configured size.
|
|
// Measuring allocations would only observe this indirectly.
|
|
func TestRouterSharesExchangeStores(t *testing.T) {
|
|
const numGoro = 8
|
|
const maxPathValues = 3
|
|
mux := newBudgetMux(maxPathValues)
|
|
cfg := DefaultRouterConfig(numGoro, 1024, maxPathValues)
|
|
var router Router
|
|
if err := router.Configure(mux, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer router.Shutdown()
|
|
|
|
wantRaw := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
|
|
if len(router.exchs) != numGoro {
|
|
t.Fatalf("got %d exchanges, want %d", len(router.exchs), numGoro)
|
|
}
|
|
if cap(router.globbuf) < numGoro*wantRaw {
|
|
t.Errorf("raw store holds %d bytes, want %d", cap(router.globbuf), numGoro*wantRaw)
|
|
}
|
|
if cap(router.globpath) < numGoro*maxPathValues {
|
|
t.Errorf("path store holds %d values, want %d", cap(router.globpath), numGoro*maxPathValues)
|
|
}
|
|
rawSeen := make(map[*byte]int, numGoro*wantRaw)
|
|
pathSeen := make(map[*PathValue]int, numGoro*maxPathValues)
|
|
for i := range router.exchs {
|
|
exch := &router.exchs[i]
|
|
if len(exch.rawbuf) != wantRaw {
|
|
t.Errorf("exchange %d: raw buffer is %d bytes, want %d", i, len(exch.rawbuf), wantRaw)
|
|
}
|
|
if len(exch.pathValues) != maxPathValues {
|
|
t.Errorf("exchange %d: %d path values, want %d", i, len(exch.pathValues), maxPathValues)
|
|
}
|
|
// Every byte must come from the shared store and belong to this exchange
|
|
// alone: an exchange allocating its own, or two sharing a window, would
|
|
// make the per-connection accounting a fiction.
|
|
for j := range exch.rawbuf {
|
|
p := &exch.rawbuf[j]
|
|
if owner, dup := rawSeen[p]; dup {
|
|
t.Fatalf("exchanges %d and %d share raw buffer byte %d", owner, i, j)
|
|
}
|
|
rawSeen[p] = i
|
|
}
|
|
for j := range exch.pathValues {
|
|
p := &exch.pathValues[j]
|
|
if owner, dup := pathSeen[p]; dup {
|
|
t.Fatalf("exchanges %d and %d share path value %d", owner, i, j)
|
|
}
|
|
pathSeen[p] = i
|
|
}
|
|
}
|
|
if len(rawSeen) != numGoro*wantRaw {
|
|
t.Errorf("exchanges cover %d raw bytes, want %d", len(rawSeen), numGoro*wantRaw)
|
|
}
|
|
}
|
|
|
|
// TestExchangeConfigureIsAllocationFree checks an exchange handed all of its
|
|
// memory allocates none of its own, which is what lets a router carve every
|
|
// exchange out of its two stores.
|
|
func TestExchangeConfigureIsAllocationFree(t *testing.T) {
|
|
cfg := ExchangeConfig{
|
|
RawBuf: make([]byte, 640),
|
|
RequestBufferLim: 512,
|
|
NumHeaderKVCap: 16,
|
|
NoRequestBufferGrowth: true,
|
|
PathValuesBuf: make([]PathValue, 4),
|
|
}
|
|
var exch Exchange
|
|
exch.Configure(cfg) // Field table allocates once, then settles.
|
|
allocs := testing.AllocsPerRun(100, func() {
|
|
exch.Configure(cfg)
|
|
})
|
|
if allocs != 0 {
|
|
t.Errorf("Exchange.Configure allocates %v times, want 0", allocs)
|
|
}
|
|
}
|
|
|
|
// TestMemoryUsagePerConnectionMatchesHeap checks the accounting against the heap
|
|
// a router actually takes, which is what makes the number worth budgeting
|
|
// against.
|
|
//
|
|
// It measures two budgets and compares the difference rather than either
|
|
// absolute figure. A router's heap carries costs the accounting does not claim
|
|
// and should not: size class rounding, the job queue's runtime header and the
|
|
// runtime's per-goroutine bookkeeping. Those are identical at both budgets, so
|
|
// subtracting cancels them and leaves only the buffers, whose growth is exactly
|
|
// what MemoryUsagePerConnection predicts. Goroutine stacks never enter into it,
|
|
// the runtime accounting them separately from the heap measured here.
|
|
func TestMemoryUsagePerConnectionMatchesHeap(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("measures heap over many Configure iterations")
|
|
}
|
|
const numGoro = 64
|
|
const maxPathValues = 3
|
|
mux := newBudgetMux(maxPathValues)
|
|
measure := func(budget int) (accounted, heap int) {
|
|
cfg := DefaultRouterConfig(numGoro, budget, maxPathValues)
|
|
res := testing.Benchmark(func(b *testing.B) {
|
|
b.ReportAllocs()
|
|
for range b.N {
|
|
var router Router
|
|
if err := router.Configure(mux, cfg); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
router.Shutdown()
|
|
}
|
|
})
|
|
return numGoro * cfg.MemoryUsagePerConnection(maxPathValues), int(res.AllocedBytesPerOp())
|
|
}
|
|
lowAcct, lowHeap := measure(2048)
|
|
highAcct, highHeap := measure(16384)
|
|
wantGrowth := highAcct - lowAcct
|
|
gotGrowth := highHeap - lowHeap
|
|
t.Logf("accounted %d->%d (+%d), heap %d->%d (+%d)",
|
|
lowAcct, highAcct, wantGrowth, lowHeap, highHeap, gotGrowth)
|
|
|
|
// What remains after cancelling is buffer growth, which the accounting covers
|
|
// term for term. Only size class rounding on the grown buffers is left over.
|
|
const tolerancePercent = 2
|
|
if diff := abs(gotGrowth - wantGrowth); diff*100 > wantGrowth*tolerancePercent {
|
|
t.Errorf("budget growth accounted %d bytes, heap grew %d (%+d)",
|
|
wantGrowth, gotGrowth, gotGrowth-wantGrowth)
|
|
}
|
|
}
|
|
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|