217 lines
4.8 KiB
Go
217 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func getFreePort(t *testing.T) int {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("get free port: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
return ln.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func TestGracefulStopPublishesOffline(t *testing.T) {
|
|
pLookup := getFreePort(t)
|
|
pFirmware := getFreePort(t)
|
|
pXMPP := getFreePort(t)
|
|
pHealth := getFreePort(t)
|
|
|
|
env := []string{
|
|
"ADVERTISE_IP=192.0.2.10",
|
|
"MQTT_HOST=mqtt.example.invalid",
|
|
fmt.Sprintf("PORT_LOOKUP=%d", pLookup),
|
|
fmt.Sprintf("PORT_FIRMWARE=%d", pFirmware),
|
|
fmt.Sprintf("PORT_XMPP=%d", pXMPP),
|
|
fmt.Sprintf("HEALTH_PORT=%d", pHealth),
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var order []string
|
|
var bridgeDeadline time.Time
|
|
var bridgeHasDeadline bool
|
|
|
|
fakeShutdownBridge := func(ctx context.Context) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
dl, ok := ctx.Deadline()
|
|
bridgeHasDeadline = ok
|
|
bridgeDeadline = dl
|
|
order = append(order, "bridge_published_retained_offline")
|
|
return nil
|
|
}
|
|
|
|
fakeShutdownXMPP := func(ctx context.Context) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
order = append(order, "xmpp_stream_closed")
|
|
return nil
|
|
}
|
|
|
|
shutdownBridge = fakeShutdownBridge
|
|
shutdownXMPP = fakeShutdownXMPP
|
|
t.Cleanup(func() {
|
|
shutdownBridge = nil
|
|
shutdownXMPP = nil
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
runDone := make(chan error, 1)
|
|
go func() {
|
|
runDone <- run(ctx, env)
|
|
}()
|
|
|
|
// Wait for health endpoint to become ready.
|
|
healthURL := fmt.Sprintf("http://127.0.0.1:%d/healthz", pHealth)
|
|
ready := false
|
|
for i := 0; i < 50; i++ {
|
|
time.Sleep(20 * time.Millisecond)
|
|
resp, err := http.Get(healthURL)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
ready = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !ready {
|
|
t.Fatal("servers did not become ready")
|
|
}
|
|
|
|
// Trigger graceful stop (simulating SIGTERM via root context cancel).
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-runDone:
|
|
if err != nil {
|
|
t.Fatalf("run returned error on graceful shutdown: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("run did not exit within 5 second shutdown budget")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(order) != 2 {
|
|
t.Fatalf("expected 2 shutdown actions, got %d: %v", len(order), order)
|
|
}
|
|
if order[0] != "bridge_published_retained_offline" {
|
|
t.Errorf("expected step 1 to be bridge publishing offline, got: %s", order[0])
|
|
}
|
|
if order[1] != "xmpp_stream_closed" {
|
|
t.Errorf("expected step 2 to be xmpp stream closed, got: %s", order[1])
|
|
}
|
|
|
|
if !bridgeHasDeadline {
|
|
t.Error("shutdownBridge context did not have a timeout deadline")
|
|
} else {
|
|
remaining := time.Until(bridgeDeadline)
|
|
if remaining > 5*time.Second {
|
|
t.Errorf("shutdown timeout deadline > 5s: %v", remaining)
|
|
}
|
|
}
|
|
}
|
|
|
|
type safeBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *safeBuffer) Write(p []byte) (n int, err error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *safeBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
func TestShutdownBudgetExpiredLogsAndExitsZero(t *testing.T) {
|
|
origTimeout := shutdownTimeout
|
|
shutdownTimeout = 50 * time.Millisecond
|
|
defer func() { shutdownTimeout = origTimeout }()
|
|
|
|
logBuf := &safeBuffer{}
|
|
origLogOutput := logOutput
|
|
logOutput = logBuf
|
|
defer func() { logOutput = origLogOutput }()
|
|
|
|
pLookup := getFreePort(t)
|
|
pFirmware := getFreePort(t)
|
|
pXMPP := getFreePort(t)
|
|
pHealth := getFreePort(t)
|
|
|
|
env := []string{
|
|
"ADVERTISE_IP=192.0.2.10",
|
|
"MQTT_HOST=mqtt.example.invalid",
|
|
fmt.Sprintf("PORT_LOOKUP=%d", pLookup),
|
|
fmt.Sprintf("PORT_FIRMWARE=%d", pFirmware),
|
|
fmt.Sprintf("PORT_XMPP=%d", pXMPP),
|
|
fmt.Sprintf("HEALTH_PORT=%d", pHealth),
|
|
}
|
|
|
|
// Slow bridge shutdown that exceeds the budget.
|
|
shutdownBridge = func(ctx context.Context) error {
|
|
<-ctx.Done() // wait for timeout
|
|
return ctx.Err()
|
|
}
|
|
shutdownXMPP = func(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
shutdownBridge = nil
|
|
shutdownXMPP = nil
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
runDone := make(chan error, 1)
|
|
go func() {
|
|
runDone <- run(ctx, env)
|
|
}()
|
|
|
|
// Wait for health endpoint.
|
|
healthURL := fmt.Sprintf("http://127.0.0.1:%d/healthz", pHealth)
|
|
for i := 0; i < 50; i++ {
|
|
time.Sleep(20 * time.Millisecond)
|
|
resp, err := http.Get(healthURL)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-runDone:
|
|
if err != nil {
|
|
t.Fatalf("expected exit 0 (nil error) on budget expiration, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("run did not return within expected window")
|
|
}
|
|
|
|
out := logBuf.String()
|
|
if !strings.Contains(out, "shutdown budget expired") {
|
|
t.Fatalf("expected log 'shutdown budget expired', got: %s", out)
|
|
}
|
|
}
|