323 lines
10 KiB
Go
323 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
|
)
|
|
|
|
func TestConfiguredTransportLifecycle(t *testing.T) {
|
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
|
for _, timeout := range []bool{false, true} {
|
|
name := transport + "/drain"
|
|
if timeout {
|
|
name = transport + "/timeout"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
|
limit := 3 * time.Second
|
|
if timeout {
|
|
limit = 80 * time.Millisecond
|
|
}
|
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(limit))
|
|
started, release, canceled := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
var once sync.Once
|
|
unblock := func() { once.Do(func() { close(release) }) }
|
|
defer unblock()
|
|
var cleaned atomic.Int32
|
|
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
|
close(started)
|
|
select {
|
|
case <-ctx.Context().Done():
|
|
close(canceled)
|
|
return ctx.Context().Err()
|
|
case <-release:
|
|
return ctx.OK("finished")
|
|
}
|
|
})
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
defer listener.Close()
|
|
done := make(chan error, 1)
|
|
go func() { done <- application.ServeContext(ctx, wrapped) }()
|
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
|
defer client.CloseIdleConnections()
|
|
requestDone := make(chan error, 1)
|
|
go func() {
|
|
response, err := client.Get("https://" + listener.Addr().String() + "/slow")
|
|
if err == nil {
|
|
defer response.Body.Close()
|
|
body, readErr := io.ReadAll(response.Body)
|
|
err = readErr
|
|
if response.StatusCode != 200 || !strings.Contains(string(body), "finished") {
|
|
err = errors.New("drained response was lost")
|
|
}
|
|
}
|
|
requestDone <- err
|
|
}()
|
|
await(t, started)
|
|
began := time.Now()
|
|
cancel()
|
|
await(t, wrapped.closed)
|
|
if !timeout {
|
|
select {
|
|
case <-canceled:
|
|
t.Fatal("active request canceled before drain")
|
|
default:
|
|
}
|
|
if cleaned.Load() != 0 {
|
|
t.Fatal("cleanup preceded active request")
|
|
}
|
|
unblock()
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if timeout {
|
|
if !errors.Is(err, context.DeadlineExceeded) || time.Since(began) < limit {
|
|
t.Fatalf("timeout ignored: %v after %v", err, time.Since(began))
|
|
}
|
|
await(t, canceled)
|
|
} else if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("server failed to stop")
|
|
}
|
|
select {
|
|
case err := <-requestDone:
|
|
if !timeout && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("client failed to finish")
|
|
}
|
|
_ = application.Close()
|
|
if cleaned.Load() != 1 {
|
|
t.Fatalf("cleanup count %d", cleaned.Load())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfiguredStartupFailureClosesResources(t *testing.T) {
|
|
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer occupied.Close()
|
|
for _, test := range []struct {
|
|
name, errorText string
|
|
options []config.Option
|
|
}{
|
|
{"unknown", "transport", []config.Option{config.WithTransport("unknown")}},
|
|
{"http2WithoutTLS", "tls", []config.Option{config.WithTransport("http2")}},
|
|
{"partialTLS", "tls", []config.Option{config.WithTLS("missing.crt", "")}},
|
|
{"nethttpCertificate", "load TLS certificate", []config.Option{config.WithTLS("missing.crt", "missing.key")}},
|
|
{"nativeCertificate", "load TLS certificate", []config.Option{config.WithTransport("native"), config.WithTLS("missing.crt", "missing.key")}},
|
|
{"http2Certificate", "load TLS certificate", []config.Option{config.WithTransport("http2"), config.WithTLS("missing.crt", "missing.key")}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
application := New(test.options...)
|
|
cleanupError := errors.New("cleanup failure")
|
|
count := 0
|
|
application.OnShutdown(func() error { count++; return cleanupError })
|
|
// The occupied address must not mask configuration/certificate errors.
|
|
err := application.RunContext(context.Background(), occupied.Addr().String())
|
|
if err == nil || !strings.Contains(err.Error(), test.errorText) || !errors.Is(err, cleanupError) {
|
|
t.Fatalf("startup: %v", err)
|
|
}
|
|
_ = application.Close()
|
|
if count != 1 {
|
|
t.Fatalf("cleanup count %d", count)
|
|
}
|
|
})
|
|
}
|
|
for _, canceled := range []bool{false, true} {
|
|
application := New(config.WithTransport("unknown"))
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if canceled {
|
|
cancel()
|
|
}
|
|
err = application.ServeContext(ctx, listener)
|
|
cancel()
|
|
if canceled && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !canceled && err == nil {
|
|
t.Fatal("invalid transport accepted")
|
|
}
|
|
if _, err := listener.Accept(); !errors.Is(err, net.ErrClosed) {
|
|
t.Fatalf("listener leaked: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunContextConfiguredTLS(t *testing.T) {
|
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
|
t.Run(transport, func(t *testing.T) {
|
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
|
reserved, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
address := reserved.Addr().String()
|
|
reserved.Close()
|
|
settings := config.Default()
|
|
settings.Address, settings.Transport = address, transport
|
|
settings.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
|
application := New(config.WithConfig(settings))
|
|
application.GET("/health", func(ctx *httpx.Context) error { return ctx.OK("ok") })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- application.RunContext(ctx) }()
|
|
defer func() {
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("RunContext failed to stop")
|
|
}
|
|
}()
|
|
client := &http.Client{Timeout: time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
|
defer client.CloseIdleConnections()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
response, err := client.Get("https://" + address + "/health")
|
|
if err == nil {
|
|
body, readErr := io.ReadAll(response.Body)
|
|
response.Body.Close()
|
|
if readErr != nil || response.StatusCode != 200 || !strings.Contains(string(body), `"data":"ok"`) {
|
|
t.Fatalf("response %s: %v", body, readErr)
|
|
}
|
|
want := "h2"
|
|
if transport == config.TransportNative {
|
|
want = "http/1.1"
|
|
}
|
|
if response.TLS.NegotiatedProtocol != want || len(response.TLS.VerifiedChains) == 0 {
|
|
t.Fatalf("TLS negotiation: %+v", response.TLS)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type runnerFunc func(context.Context, http.Handler, string) error
|
|
|
|
func (f runnerFunc) Run(ctx context.Context, handler http.Handler, address string) error {
|
|
return f(ctx, handler, address)
|
|
}
|
|
|
|
func TestRunWithRemainsIndependentOfBuiltInTransport(t *testing.T) {
|
|
for _, canceled := range []bool{false, true} {
|
|
application := New(config.WithTransport("custom"), config.WithTLS("missing", "missing"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if canceled {
|
|
cancel()
|
|
}
|
|
calls, cleanups := 0, 0
|
|
application.OnShutdown(func() error { cleanups++; return nil })
|
|
err := application.RunWith(ctx, runnerFunc(func(_ context.Context, handler http.Handler, address string) error {
|
|
calls++
|
|
if handler != application || address != "127.0.0.1:1234" {
|
|
t.Fatal("custom runner arguments changed")
|
|
}
|
|
return nil
|
|
}), "127.0.0.1:1234")
|
|
cancel()
|
|
wantCalls := 1
|
|
if canceled {
|
|
wantCalls = 0
|
|
}
|
|
if err != nil || calls != wantCalls || cleanups != 1 {
|
|
t.Fatalf("RunWith: %v calls=%d cleanups=%d", err, calls, cleanups)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
|
t.Run(transport, func(t *testing.T) {
|
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
|
started := make(chan context.Context, 1)
|
|
release, finished := make(chan struct{}), make(chan struct{})
|
|
application.GET("/blocked", func(ctx *httpx.Context) error {
|
|
defer close(finished)
|
|
started <- ctx.Context()
|
|
<-release
|
|
return nil
|
|
})
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Close()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() { done <- application.ServeContext(ctx, listener) }()
|
|
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
|
defer client.CloseIdleConnections()
|
|
requestDone := make(chan struct{})
|
|
go func() {
|
|
defer close(requestDone)
|
|
response, err := client.Get("https://" + listener.Addr().String() + "/blocked")
|
|
if err == nil {
|
|
response.Body.Close()
|
|
}
|
|
}()
|
|
var requestContext context.Context
|
|
select {
|
|
case requestContext = <-started:
|
|
case <-time.After(3 * time.Second):
|
|
close(release)
|
|
t.Fatal("handler did not start")
|
|
}
|
|
defer func() { close(release); await(t, finished) }()
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handler defeated shutdown deadline")
|
|
}
|
|
// net/http's HTTP/2 streams observe the closed socket asynchronously.
|
|
await(t, requestContext.Done())
|
|
await(t, requestDone)
|
|
})
|
|
}
|
|
}
|