mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
354 lines
9.2 KiB
Go
354 lines
9.2 KiB
Go
package zip
|
|
|
|
import (
|
|
enc "encoding/binary"
|
|
"io"
|
|
"net"
|
|
nethttp "net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
zaphttp "github.com/zap-proto/http"
|
|
)
|
|
|
|
// A hold is released on every way a request can end, and the ceiling admits
|
|
// over itself only when nothing could ever make room. Each test here is a path
|
|
// that left a plugin held for good, or let a burst past the ceiling.
|
|
|
|
var (
|
|
holdBinOnce sync.Once
|
|
holdBin []byte
|
|
holdBinErr error
|
|
)
|
|
|
|
func holdPlugin(t *testing.T) []byte {
|
|
t.Helper()
|
|
holdBinOnce.Do(func() {
|
|
d, err := os.MkdirTemp("", "hold")
|
|
if err != nil {
|
|
holdBinErr = err
|
|
return
|
|
}
|
|
defer os.RemoveAll(d)
|
|
out := filepath.Join(d, "tp")
|
|
cmd := exec.Command("go", "build", "-o", out, "./internal/testplugin")
|
|
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
|
if b, err := cmd.CombinedOutput(); err != nil {
|
|
holdBinErr = err
|
|
t.Logf("%s", b)
|
|
return
|
|
}
|
|
holdBin, holdBinErr = os.ReadFile(out)
|
|
})
|
|
if holdBinErr != nil {
|
|
t.Skipf("cannot build test plugin: %v", holdBinErr)
|
|
}
|
|
return holdBin
|
|
}
|
|
|
|
func holdHost(t *testing.T, warm int, specs ...Plugin) *App {
|
|
t.Helper()
|
|
app := New(Config{AppName: "host", DisableStartupMessage: true, Warm: warm})
|
|
for _, p := range specs {
|
|
l, err := Load(p, "/v1/"+p.Name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.Use(l)
|
|
}
|
|
t.Cleanup(func() { _ = app.Shutdown() })
|
|
return app
|
|
}
|
|
|
|
func lazy(t *testing.T, name string, idle time.Duration) Plugin {
|
|
return Plugin{Name: name, Bin: holdPlugin(t), Dir: sockDir(t), Lazy: true, IdleAfter: idle}
|
|
}
|
|
|
|
func busyOf(a *App, name string) int64 {
|
|
_, p := a.pluginNamed(name)
|
|
if in := p.cur.Load(); in != nil {
|
|
return in.busy.Load()
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func running(a *App) int {
|
|
n := 0
|
|
for _, p := range a.pluginSet() {
|
|
if p.cur.Load() != nil {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func get(t *testing.T, a *App, target string, hdr ...string) (int, string) {
|
|
t.Helper()
|
|
req, _ := nethttp.NewRequest("GET", target, nil)
|
|
for i := 0; i+1 < len(hdr); i += 2 {
|
|
req.Header.Set(hdr[i], hdr[i+1])
|
|
}
|
|
resp, err := a.Test(req, TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true})
|
|
if err != nil {
|
|
t.Errorf("GET %s: %v", target, err) // Errorf: callers run it off the test goroutine
|
|
return 0, ""
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
|
|
func serveZAP(t *testing.T, a *App) string {
|
|
t.Helper()
|
|
sock := filepath.Join(sockDir(t), "h.sock")
|
|
go func() { _ = a.Listen(sock) }()
|
|
for range 300 {
|
|
if c, err := net.Dial("unix", sock); err == nil {
|
|
_ = c.Close()
|
|
return sock
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("host never listened")
|
|
return ""
|
|
}
|
|
|
|
func settle(limit time.Duration, ok func() bool) bool {
|
|
for end := time.Now().Add(limit); time.Now().Before(end); time.Sleep(10 * time.Millisecond) {
|
|
if ok() {
|
|
return true
|
|
}
|
|
}
|
|
return ok()
|
|
}
|
|
|
|
// A ZAP caller that leaves before a streamed head is written releases its hold:
|
|
// the connection ending closes the body, and the body carries the hold.
|
|
func TestHold_ACallerWhoLeavesOverZAPReleases(t *testing.T) {
|
|
app := holdHost(t, 1, lazy(t, "demo0", 100*time.Millisecond))
|
|
sock := serveZAP(t, app)
|
|
|
|
var req fasthttp.Request
|
|
req.Header.SetMethod("GET")
|
|
req.SetRequestURI("/v1/demo0/feed?drip=200ms")
|
|
buf, err := zaphttp.AppendRequest([]byte{0, 0, 0, 0}, &req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
enc.BigEndian.PutUint32(buf, uint32(len(buf)-4))
|
|
c, err := net.Dial("unix", sock)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.Write(buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = c.Close() // gone before the plugin even starts
|
|
|
|
if !settle(5*time.Second, func() bool { return busyOf(app, "demo0") >= 0 }) {
|
|
t.Fatal("demo0 never started for the request")
|
|
}
|
|
if !settle(5*time.Second, func() bool { return busyOf(app, "demo0") == 0 }) {
|
|
t.Fatalf("demo0 still holds %d requests after its only caller left", busyOf(app, "demo0"))
|
|
}
|
|
}
|
|
|
|
// An upgrade cannot cross ZAP, whose server never runs a hijack. It is refused,
|
|
// and the hold it took is released.
|
|
func TestHold_AnUpgradeOverZAPIsRefused(t *testing.T) {
|
|
app := holdHost(t, 1, lazy(t, "demo0", time.Hour))
|
|
sock := serveZAP(t, app)
|
|
|
|
tr := zaphttp.Dial("unix", sock)
|
|
defer tr.CloseIdleConnections()
|
|
var req fasthttp.Request
|
|
var resp fasthttp.Response
|
|
req.SetRequestURI("/v1/demo0/socket")
|
|
req.Header.SetHost("box")
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
if err := tr.Do(&req, &resp); err != nil {
|
|
t.Fatalf("do: %v", err)
|
|
}
|
|
if resp.StatusCode() != 501 {
|
|
t.Fatalf("status %d, want 501 for an upgrade over ZAP", resp.StatusCode())
|
|
}
|
|
if b := busyOf(app, "demo0"); b > 0 {
|
|
t.Fatalf("demo0 holds %d requests after the refusal", b)
|
|
}
|
|
}
|
|
|
|
// A plugin that declines an upgrade with a chunked reply is answered once the
|
|
// reply ends, and the hold is released. It used to read "until the connection
|
|
// closes", which a keep-alive plugin never does.
|
|
func TestHold_ADeclinedUpgradeWithAChunkedReplyEnds(t *testing.T) {
|
|
app := holdHost(t, 1, lazy(t, "demo0", time.Hour))
|
|
|
|
done := make(chan struct{})
|
|
var status int
|
|
var body string
|
|
go func() {
|
|
defer close(done)
|
|
status, body = get(t, app, "/v1/demo0/feed?drip=300ms", "Connection", "Upgrade", "Upgrade", "websocket")
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatalf("the declined upgrade never answered; demo0 holds %d", busyOf(app, "demo0"))
|
|
}
|
|
if status != 200 || !strings.HasSuffix(body, "done\n") {
|
|
t.Fatalf("status %d body %q, want the plugin's whole reply", status, body)
|
|
}
|
|
if b := busyOf(app, "demo0"); b != 0 {
|
|
t.Fatalf("demo0 holds %d after the reply ended", b)
|
|
}
|
|
}
|
|
|
|
// A cold burst stays under the ceiling. Starts in flight are neither idle nor
|
|
// busy, and they used to read as "nothing may be stopped", which admitted every
|
|
// one of them over the ceiling.
|
|
func TestHold_AColdBurstStaysUnderTheCeiling(t *testing.T) {
|
|
const warm, n = 2, 8
|
|
var specs []Plugin
|
|
for i := range n {
|
|
specs = append(specs, lazy(t, "demo"+string(rune('0'+i)), time.Hour))
|
|
}
|
|
app := holdHost(t, warm, specs...)
|
|
|
|
stop := make(chan struct{})
|
|
peak := 0
|
|
var mu sync.Mutex
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
if r := running(app); r > peak {
|
|
mu.Lock()
|
|
peak = r
|
|
mu.Unlock()
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}()
|
|
var wg sync.WaitGroup
|
|
codes := make([]int, n)
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
codes[i], _ = get(t, app, "/v1/demo"+string(rune('0'+i))+"/version")
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(stop)
|
|
for i, c := range codes {
|
|
if c != 200 {
|
|
t.Errorf("demo%d answered %d", i, c)
|
|
}
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if peak > warm {
|
|
t.Fatalf("peak %d processes against a ceiling of %d", peak, warm)
|
|
}
|
|
}
|
|
|
|
// When the coldest candidate cannot be stopped — its lock is held mid-Reload —
|
|
// room is taken from the next one rather than refused.
|
|
func TestHold_ALockedCandidateDoesNotBlockRoom(t *testing.T) {
|
|
app := holdHost(t, 2,
|
|
lazy(t, "demo0", time.Hour), lazy(t, "demo1", time.Hour), lazy(t, "demo2", time.Hour))
|
|
for _, n := range []string{"demo0", "demo1"} {
|
|
if c, _ := get(t, app, "/v1/"+n+"/version"); c != 200 {
|
|
t.Fatalf("%s: %d", n, c)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
_, cold := app.pluginNamed("demo0")
|
|
cold.mu.Lock() // demo0, the coldest, is mid-transition
|
|
defer cold.mu.Unlock()
|
|
|
|
if c, _ := get(t, app, "/v1/demo2/version"); c != 200 {
|
|
t.Fatalf("demo2 answered %d with demo1 idle and stoppable", c)
|
|
}
|
|
_, warmer := app.pluginNamed("demo1")
|
|
if warmer.cur.Load() != nil {
|
|
t.Fatal("demo1 is still running; room came from nowhere")
|
|
}
|
|
}
|
|
|
|
// Callers queued on one starter each wait their own bound, not one after
|
|
// another's: the wait is counted from when the caller arrived.
|
|
func TestHold_QueuedStartersWaitTogether(t *testing.T) {
|
|
slow := lazy(t, "demo1", time.Hour)
|
|
slow.Start = 400 * time.Millisecond
|
|
app := holdHost(t, 1, lazy(t, "demo0", time.Hour), slow)
|
|
|
|
go func() { _, _ = get(t, app, "/v1/demo0/version?sleep=4s") }()
|
|
if !settle(5*time.Second, func() bool { return busyOf(app, "demo0") == 1 }) {
|
|
t.Fatal("demo0 never became busy")
|
|
}
|
|
const callers = 5
|
|
var wg sync.WaitGroup
|
|
took := make([]time.Duration, callers)
|
|
for i := range callers {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
start := time.Now()
|
|
if c, _ := get(t, app, "/v1/demo1/version"); c != 503 {
|
|
t.Errorf("caller %d: %d, want 503 while the only process is busy", i, c)
|
|
}
|
|
took[i] = time.Since(start)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i, d := range took {
|
|
if d > 1500*time.Millisecond {
|
|
t.Errorf("caller %d waited %s against its own 400ms bound", i, d)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reload rewrites spec under p.mu while requests read the plugin unlocked. Run
|
|
// with -race: the detector is the assertion.
|
|
func TestHold_ReloadRacesNoRequest(t *testing.T) {
|
|
app := holdHost(t, 1, lazy(t, "demo0", time.Hour))
|
|
if c, _ := get(t, app, "/v1/demo0/version"); c != 200 {
|
|
t.Fatalf("demo0: %d", c)
|
|
}
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
for range 4 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
_, _ = get(t, app, "/v1/demo0/version")
|
|
app.Evict()
|
|
}
|
|
}()
|
|
}
|
|
for range 3 {
|
|
if err := app.Reload("demo0", Plugin{}); err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
}
|
|
close(stop)
|
|
wg.Wait()
|
|
}
|