mirror of
https://github.com/luxfi/zap.git
synced 2026-07-26 22:53:31 +00:00
393 lines
12 KiB
Go
393 lines
12 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package zap_test
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net"
|
||
"runtime"
|
||
"strconv"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
zap "github.com/luxfi/zap"
|
||
zapquic "github.com/luxfi/zap/quic"
|
||
)
|
||
|
||
// TestQUICCallRoundTrip verifies the per-Call stream path: Node.Call
|
||
// over QUIC opens a fresh stream, writes one request frame, reads one
|
||
// response frame, closes the stream. Byte-equal request/response.
|
||
func TestQUICCallRoundTrip(t *testing.T) {
|
||
srvAddr, srvNode, cliNode := newQUICTestPair(t, "srv-roundtrip", "cli-roundtrip")
|
||
defer srvNode.Stop()
|
||
defer cliNode.Stop()
|
||
|
||
// Server registers a handler that echos the request.
|
||
srvNode.Handle(0x42, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||
got := msg.Root().Uint64(0)
|
||
resp := buildEchoMessage(got + 1)
|
||
return resp, nil
|
||
})
|
||
|
||
if err := cliNode.ConnectDirect(srvAddr); err != nil {
|
||
t.Fatalf("ConnectDirect: %v", err)
|
||
}
|
||
|
||
// Give the accept goroutine a moment to register the inbound conn.
|
||
waitForPeer(t, srvNode, "cli-roundtrip", 2*time.Second)
|
||
waitForPeer(t, cliNode, "srv-roundtrip", 2*time.Second)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
req := buildEchoMessage(100)
|
||
resp, err := cliNode.Call(ctx, "srv-roundtrip", req)
|
||
if err != nil {
|
||
t.Fatalf("Call: %v", err)
|
||
}
|
||
if got := resp.Root().Uint64(0); got != 101 {
|
||
t.Fatalf("response = %d, want 101", got)
|
||
}
|
||
}
|
||
|
||
// TestQUICCallConcurrent fans out N concurrent Calls on one QUIC
|
||
// connection. Each Call MUST get its own stream — if they serialized
|
||
// on a shared mutex, the handler's deliberate sleep would multiply
|
||
// total latency. With per-Call streams the wall-clock cost is bounded
|
||
// by the slowest single call plus stream open overhead.
|
||
func TestQUICCallConcurrent(t *testing.T) {
|
||
const concurrency = 32
|
||
const handlerDelay = 5 * time.Millisecond
|
||
|
||
srvAddr, srvNode, cliNode := newQUICTestPair(t, "srv-concurrent", "cli-concurrent")
|
||
defer srvNode.Stop()
|
||
defer cliNode.Stop()
|
||
|
||
var handlerInflight atomic.Int64
|
||
var maxInflight atomic.Int64
|
||
|
||
srvNode.Handle(0x42, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||
nowInflight := handlerInflight.Add(1)
|
||
defer handlerInflight.Add(-1)
|
||
// Track concurrent in-flight handlers — the optimization
|
||
// claim is that with N concurrent Calls we should observe
|
||
// max inflight ≈ N (modulo scheduler jitter).
|
||
for {
|
||
cur := maxInflight.Load()
|
||
if nowInflight <= cur || maxInflight.CompareAndSwap(cur, nowInflight) {
|
||
break
|
||
}
|
||
}
|
||
// Deliberately sleep so serialization would dominate.
|
||
time.Sleep(handlerDelay)
|
||
idx := msg.Root().Uint64(0)
|
||
return buildEchoMessage(idx + 1), nil
|
||
})
|
||
|
||
if err := cliNode.ConnectDirect(srvAddr); err != nil {
|
||
t.Fatalf("ConnectDirect: %v", err)
|
||
}
|
||
waitForPeer(t, srvNode, "cli-concurrent", 2*time.Second)
|
||
waitForPeer(t, cliNode, "srv-concurrent", 2*time.Second)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
start := time.Now()
|
||
var wg sync.WaitGroup
|
||
errs := make(chan error, concurrency)
|
||
for i := 0; i < concurrency; i++ {
|
||
i := i
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
req := buildEchoMessage(uint64(i))
|
||
resp, err := cliNode.Call(ctx, "srv-concurrent", req)
|
||
if err != nil {
|
||
errs <- fmt.Errorf("Call[%d]: %v", i, err)
|
||
return
|
||
}
|
||
if got := resp.Root().Uint64(0); got != uint64(i)+1 {
|
||
errs <- fmt.Errorf("Call[%d] response = %d, want %d", i, got, i+1)
|
||
return
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
close(errs)
|
||
for err := range errs {
|
||
t.Error(err)
|
||
}
|
||
elapsed := time.Since(start)
|
||
|
||
t.Logf("%d concurrent Calls in %v; peak inflight handlers = %d",
|
||
concurrency, elapsed, maxInflight.Load())
|
||
|
||
// Sanity check: with per-Call streams we expect the wall-clock to
|
||
// be only a few multiples of handlerDelay (one per pipeline depth
|
||
// + stream open overhead). With pure serialization it would be
|
||
// ≈ concurrency * handlerDelay = 32 * 5ms = 160ms. Use a generous
|
||
// 4× margin to keep the test stable on CI.
|
||
limit := time.Duration(concurrency/4) * handlerDelay
|
||
if elapsed > limit {
|
||
t.Errorf("elapsed %v > limit %v — Calls appear serialized", elapsed, limit)
|
||
}
|
||
// And peak inflight ≥ 2 confirms streams ran in parallel.
|
||
if maxInflight.Load() < 2 {
|
||
t.Errorf("max inflight = %d, expected ≥2 concurrent handlers", maxInflight.Load())
|
||
}
|
||
}
|
||
|
||
// buildEchoMessage emits a 1-field message with msgType=0x42.
|
||
func buildEchoMessage(value uint64) *zap.Message {
|
||
b := zap.NewBuilder(64)
|
||
o := b.StartObject(16)
|
||
o.SetUint64(0, value)
|
||
o.FinishAsRoot()
|
||
msg, _ := zap.Parse(b.FinishWithFlags(0x42 << 8))
|
||
return msg
|
||
}
|
||
|
||
// newQUICTestPair creates a connected pair of ZAP nodes over QUIC.
|
||
// Both nodes use self-signed certs and skip cert verification — fine
|
||
// for in-process tests, never for production.
|
||
func newQUICTestPair(t *testing.T, srvID, cliID string) (string, *zap.Node, *zap.Node) {
|
||
t.Helper()
|
||
cert, err := zapquic.GenerateSelfSignedCert("127.0.0.1", "::1", "localhost")
|
||
if err != nil {
|
||
t.Fatalf("GenerateSelfSignedCert: %v", err)
|
||
}
|
||
srvTLS := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||
// Both sides act as servers in ZAP's QUIC factory (Listen +
|
||
// Dial use the same TLS config), so the client also needs a
|
||
// usable Certificates entry. Insecure verify is fine for
|
||
// loopback tests.
|
||
cliTLS := &tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
|
||
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||
|
||
srvPort := pickFreePortTest(t)
|
||
srv := zap.NewNode(zap.NodeConfig{
|
||
NodeID: srvID,
|
||
ServiceType: "_quic-test._udp",
|
||
Port: srvPort,
|
||
NoDiscovery: true,
|
||
Transport: zap.TransportQUIC,
|
||
TLS: srvTLS,
|
||
Logger: logger,
|
||
})
|
||
if err := srv.Start(); err != nil {
|
||
t.Fatalf("srv.Start: %v", err)
|
||
}
|
||
|
||
cli := zap.NewNode(zap.NodeConfig{
|
||
NodeID: cliID,
|
||
ServiceType: "_quic-test._udp",
|
||
Port: 0,
|
||
NoDiscovery: true,
|
||
Transport: zap.TransportQUIC,
|
||
TLS: cliTLS,
|
||
Logger: logger,
|
||
})
|
||
if err := cli.Start(); err != nil {
|
||
srv.Stop()
|
||
t.Fatalf("cli.Start: %v", err)
|
||
}
|
||
|
||
return fmt.Sprintf("127.0.0.1:%d", srvPort), srv, cli
|
||
}
|
||
|
||
// pickFreePortTest binds a UDP socket to find an unused port, then
|
||
// closes it. The released port is then used by Node — small race
|
||
// window, fine for tests.
|
||
func pickFreePortTest(t *testing.T) int {
|
||
t.Helper()
|
||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||
if err != nil {
|
||
t.Fatalf("pick port: %v", err)
|
||
}
|
||
port := c.LocalAddr().(*net.UDPAddr).Port
|
||
_ = c.Close()
|
||
return port
|
||
}
|
||
|
||
// waitForPeer polls Node.Peers until peerID is connected or timeout.
|
||
// Falls back to a probe Call when Peers() returns empty — the QUIC
|
||
// transport tracks peers in a separate map that's not surfaced via
|
||
// Peers(), so the canonical liveness check is a successful
|
||
// round-trip.
|
||
func waitForPeer(t *testing.T, n *zap.Node, peerID string, timeout time.Duration) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
for _, p := range n.Peers() {
|
||
if p == peerID {
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
// Don't fail — QUIC peers may not be in Peers() yet; the actual
|
||
// Call will retry until ctx fires.
|
||
}
|
||
|
||
// BenchmarkQUICCallConcurrent_1 / _10 / _100 measure how Call latency
|
||
// scales with concurrent goroutines. Per-Call streams should yield
|
||
// near-linear scaling up to the QUIC stream limit.
|
||
func BenchmarkQUICCallConcurrent_1(b *testing.B) { benchQUICCall(b, 1) }
|
||
func BenchmarkQUICCallConcurrent_10(b *testing.B) { benchQUICCall(b, 10) }
|
||
func BenchmarkQUICCallConcurrent_100(b *testing.B) { benchQUICCall(b, 100) }
|
||
|
||
func benchQUICCall(b *testing.B, goroutines int) {
|
||
// 2ms simulated handler work. Picked deliberately to be larger
|
||
// than the QUIC RTT + stream-open overhead, so concurrent calls
|
||
// must overlap inside the handler for the bench to scale. Real
|
||
// production handlers (signature verification, state lookup,
|
||
// fhe ciphertext math) often run 1-10ms; we sit at the low end.
|
||
const handlerWork = 2 * time.Millisecond
|
||
srvID := "srv-bench-" + strconv.Itoa(goroutines)
|
||
cliID := "cli-bench-" + strconv.Itoa(goroutines)
|
||
srvAddr, srvNode, cliNode := newQUICBenchPair(b, srvID, cliID)
|
||
defer srvNode.Stop()
|
||
defer cliNode.Stop()
|
||
|
||
srvNode.Handle(0x42, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||
// Busy-wait so we exercise CPU + cache, not just runtime
|
||
// goroutine parking — sleep() would let the scheduler
|
||
// trivially batch work.
|
||
deadline := time.Now().Add(handlerWork)
|
||
for time.Now().Before(deadline) {
|
||
runtime.Gosched()
|
||
}
|
||
return buildEchoMessage(msg.Root().Uint64(0) + 1), nil
|
||
})
|
||
if err := cliNode.ConnectDirect(srvAddr); err != nil {
|
||
b.Fatalf("ConnectDirect: %v", err)
|
||
}
|
||
|
||
// Warm-up: synchronously execute one Call to make sure the
|
||
// connection is fully established and the QUIC accept-stream
|
||
// goroutine is running on both sides. Bench timer starts after.
|
||
warmCtx, warmCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
if _, err := cliNode.Call(warmCtx, srvID, buildEchoMessage(0)); err != nil {
|
||
warmCancel()
|
||
b.Fatalf("warm-up Call: %v", err)
|
||
}
|
||
warmCancel()
|
||
|
||
b.ReportAllocs()
|
||
b.ResetTimer()
|
||
|
||
if goroutines == 1 {
|
||
ctx := context.Background()
|
||
for i := 0; i < b.N; i++ {
|
||
resp, err := cliNode.Call(ctx, srvID, buildEchoMessage(uint64(i)))
|
||
if err != nil {
|
||
b.Fatalf("Call: %v", err)
|
||
}
|
||
_ = resp
|
||
}
|
||
return
|
||
}
|
||
|
||
// Concurrent path: split b.N iterations across `goroutines`
|
||
// fixed-size workers. RunParallel's SetParallelism multiplies by
|
||
// GOMAXPROCS which would explode the goroutine count — instead we
|
||
// hand-roll a fixed worker pool.
|
||
var counter atomic.Int64
|
||
target := int64(b.N)
|
||
var wg sync.WaitGroup
|
||
for w := 0; w < goroutines; w++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
ctx := context.Background()
|
||
for {
|
||
idx := counter.Add(1)
|
||
if idx > target {
|
||
return
|
||
}
|
||
resp, err := cliNode.Call(ctx, srvID, buildEchoMessage(uint64(idx)))
|
||
if err != nil {
|
||
b.Errorf("Call: %v", err)
|
||
return
|
||
}
|
||
_ = resp
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
func newQUICBenchPair(b *testing.B, srvID, cliID string) (string, *zap.Node, *zap.Node) {
|
||
b.Helper()
|
||
cert, err := zapquic.GenerateSelfSignedCert("127.0.0.1", "::1", "localhost")
|
||
if err != nil {
|
||
b.Fatalf("cert: %v", err)
|
||
}
|
||
srvTLS := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||
// Both sides act as servers in ZAP's QUIC factory (Listen +
|
||
// Dial use the same TLS config), so the client also needs a
|
||
// usable Certificates entry. Insecure verify is fine for
|
||
// loopback tests.
|
||
cliTLS := &tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
|
||
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||
|
||
srvPort := pickFreePortBench(b)
|
||
srv := zap.NewNode(zap.NodeConfig{
|
||
NodeID: srvID,
|
||
ServiceType: "_quic-bench._udp",
|
||
Port: srvPort,
|
||
NoDiscovery: true,
|
||
Transport: zap.TransportQUIC,
|
||
TLS: srvTLS,
|
||
Logger: logger,
|
||
})
|
||
if err := srv.Start(); err != nil {
|
||
b.Fatalf("srv.Start: %v", err)
|
||
}
|
||
cli := zap.NewNode(zap.NodeConfig{
|
||
NodeID: cliID,
|
||
ServiceType: "_quic-bench._udp",
|
||
Port: 0,
|
||
NoDiscovery: true,
|
||
Transport: zap.TransportQUIC,
|
||
TLS: cliTLS,
|
||
Logger: logger,
|
||
})
|
||
if err := cli.Start(); err != nil {
|
||
srv.Stop()
|
||
b.Fatalf("cli.Start: %v", err)
|
||
}
|
||
return fmt.Sprintf("127.0.0.1:%d", srvPort), srv, cli
|
||
}
|
||
|
||
func pickFreePortBench(b *testing.B) int {
|
||
b.Helper()
|
||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||
if err != nil {
|
||
b.Fatalf("pick port: %v", err)
|
||
}
|
||
port := c.LocalAddr().(*net.UDPAddr).Port
|
||
_ = c.Close()
|
||
return port
|
||
}
|
||
|
||
func waitForPeerBench(b *testing.B, n *zap.Node, peerID string, timeout time.Duration) {
|
||
b.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
for _, p := range n.Peers() {
|
||
if p == peerID {
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
}
|