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" "git.yuntuo.hk/k2safe/go-omni/lifecycle" ) 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), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond})) var cleaned atomic.Int32 application.OnShutdown(func() error { cleaned.Add(1); return nil }) 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) if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 { t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load()) } }() cancel() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 { 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) }) } }