284 lines
8.9 KiB
Go
284 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"example.com/plain/internal/config"
|
|
program "example.com/plain/internal/run"
|
|
"example.com/plain/internal/svc"
|
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
|
)
|
|
|
|
func setup(t *testing.T) {
|
|
t.Helper()
|
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
|
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
|
t.Cleanup(func() {
|
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
|
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
|
})
|
|
}
|
|
func shortConfig(t *testing.T, logfile string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
|
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
|
if logfile != "" {
|
|
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
|
}
|
|
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func TestFiniteRunAndCleanupOrder(t *testing.T) {
|
|
setup(t)
|
|
var events []string
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
events = append(events, "init")
|
|
c.OnClose(func() error { events = append(events, "first"); return nil })
|
|
c.OnClose(func() error { events = append(events, "second"); return nil })
|
|
return nil
|
|
}
|
|
program.Execute = func(context.Context, *svc.ServiceContext) error {
|
|
defer func() { events = append(events, "run defer") }()
|
|
events = append(events, "run")
|
|
return nil
|
|
}
|
|
if err := run(context.Background(), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
|
|
t.Fatal(events)
|
|
}
|
|
}
|
|
func TestInitializationAndRunFailures(t *testing.T) {
|
|
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
setup(t)
|
|
boom := errors.New("failure marker")
|
|
var closed atomic.Int32
|
|
var started atomic.Bool
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
c.OnClose(func() error { closed.Add(1); return nil })
|
|
if stage == "init error" {
|
|
return boom
|
|
}
|
|
if stage == "init panic" {
|
|
panic(boom)
|
|
}
|
|
return nil
|
|
}
|
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
started.Store(true)
|
|
switch stage {
|
|
case "run error":
|
|
return boom
|
|
case "run panic":
|
|
panic(boom)
|
|
case "run goexit":
|
|
runtime.Goexit()
|
|
case "component failure":
|
|
c.RequestShutdown(boom)
|
|
<-ctx.Done()
|
|
}
|
|
return nil
|
|
}
|
|
err := run(context.Background(), "")
|
|
if err == nil || closed.Load() != 1 {
|
|
t.Fatal(stage, err, closed.Load())
|
|
}
|
|
if strings.HasPrefix(stage, "init") && started.Load() {
|
|
t.Fatal("run started after failed initialization")
|
|
}
|
|
if strings.Contains(stage, "error") || stage == "component failure" {
|
|
if !errors.Is(err, boom) {
|
|
t.Fatal("failure cause lost", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
|
|
setup(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
ready := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
var closed, acked atomic.Bool
|
|
var canceledEarly atomic.Bool
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
c.OnClose(func() error {
|
|
if !acked.Load() {
|
|
return errors.New("closed before acknowledgement")
|
|
}
|
|
closed.Store(true)
|
|
return nil
|
|
})
|
|
c.OnStop("component", func(context.Context) error { close(stop); return nil })
|
|
c.OnDrain("component", func(context.Context) error { <-done; return nil })
|
|
go func() {
|
|
defer close(done)
|
|
<-stop
|
|
if c.WorkContext().Err() != nil {
|
|
canceledEarly.Store(true)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
acked.Store(true)
|
|
}()
|
|
return nil
|
|
}
|
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
|
|
result := make(chan error, 1)
|
|
go func() { result <- run(ctx, "") }()
|
|
<-ready
|
|
cancel()
|
|
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
|
|
t.Fatal(err, closed.Load(), canceledEarly.Load())
|
|
}
|
|
}
|
|
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
|
|
setup(t)
|
|
done := make(chan struct{})
|
|
stop := make(chan struct{})
|
|
var closed atomic.Bool
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
c.OnClose(func() error {
|
|
select {
|
|
case <-done:
|
|
closed.Store(true)
|
|
return nil
|
|
default:
|
|
return errors.New("resource closed while starting work still active")
|
|
}
|
|
})
|
|
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
|
|
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
|
|
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
|
|
return errors.New("later dependency failed")
|
|
}
|
|
if err := run(context.Background(), ""); err == nil || !closed.Load() {
|
|
t.Fatal(err, closed.Load())
|
|
}
|
|
}
|
|
func TestCancellationGraceJoinsWork(t *testing.T) {
|
|
setup(t)
|
|
done := make(chan struct{})
|
|
var closed atomic.Bool
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
c.OnClose(func() error { closed.Store(true); return nil })
|
|
c.OnStop("cooperative", func(context.Context) error { return nil })
|
|
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
|
|
go func() { <-c.WorkContext().Done(); close(done) }()
|
|
return nil
|
|
}
|
|
err := run(context.Background(), shortConfig(t, ""))
|
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
|
|
t.Fatal(err, closed.Load())
|
|
}
|
|
}
|
|
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
|
|
for _, which := range []string{"run", "background", "init failure"} {
|
|
t.Run(which, func(t *testing.T) {
|
|
setup(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
release := make(chan struct{})
|
|
joined := make(chan struct{})
|
|
ready := make(chan struct{})
|
|
var closed atomic.Bool
|
|
var held *svc.ServiceContext
|
|
logfile := filepath.Join(t.TempDir(), "retained.log")
|
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
held = c
|
|
c.OnClose(func() error { closed.Store(true); return nil })
|
|
if which != "run" {
|
|
c.OnStop("stuck", func(context.Context) error { return nil })
|
|
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
|
|
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
|
|
}
|
|
if which == "init failure" {
|
|
return errors.New("initialization failed")
|
|
}
|
|
return nil
|
|
}
|
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
|
close(ready)
|
|
if which == "run" {
|
|
defer close(joined)
|
|
<-release
|
|
c.Logger.Info("after shutdown deadline")
|
|
} else {
|
|
<-ctx.Done()
|
|
}
|
|
return nil
|
|
}
|
|
result := make(chan error, 1)
|
|
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
|
|
if which != "init failure" {
|
|
<-ready
|
|
cancel()
|
|
}
|
|
var err error
|
|
select {
|
|
case err = <-result:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("shutdown hung")
|
|
}
|
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
|
t.Fatal(err, closed.Load())
|
|
}
|
|
close(release)
|
|
<-joined
|
|
// In the run case the entry's completion notification follows the user defer.
|
|
// Close is idempotent and must retain resources even after late completion.
|
|
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
|
t.Fatal("retention was lost", err)
|
|
}
|
|
data, _ := os.ReadFile(logfile)
|
|
if !strings.Contains(string(data), "after shutdown deadline") {
|
|
t.Fatal("logger closed while work still running", string(data))
|
|
}
|
|
_ = held.Logger.Close()
|
|
})
|
|
}
|
|
}
|
|
func TestExplicitConfigurationAndPreflight(t *testing.T) {
|
|
setup(t)
|
|
t.Setenv("PLAIN_LEVEL", "debug")
|
|
t.Setenv("LOG_LEVEL", "error")
|
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.Load(p)
|
|
if err != nil || cfg.Log.Level != "debug" {
|
|
t.Fatal(cfg.Log.Level, err)
|
|
}
|
|
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err = config.Load(p)
|
|
if err != nil || cfg.Log.Level != "info" {
|
|
t.Fatal("implicit environment override", err)
|
|
}
|
|
var initCalled atomic.Bool
|
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
|
|
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = run(context.Background(), p); err == nil || initCalled.Load() {
|
|
t.Fatal("invalid configuration initialized dependencies", err)
|
|
}
|
|
}
|