275 lines
7.2 KiB
Go
275 lines
7.2 KiB
Go
package host
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type traceLog struct {
|
|
mu sync.Mutex
|
|
events []string
|
|
}
|
|
|
|
func (l *traceLog) add(event string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.events = append(l.events, event)
|
|
}
|
|
|
|
func (l *traceLog) snapshot() []string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return append([]string(nil), l.events...)
|
|
}
|
|
|
|
type fakeComponent struct {
|
|
name string
|
|
trace *traceLog
|
|
startErr error
|
|
stopErr error
|
|
startCtx context.Context
|
|
startDone chan struct{}
|
|
blockOnCancel bool
|
|
stopStarted chan struct{}
|
|
stopRelease chan struct{}
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
func (c *fakeComponent) Name() string { return c.name }
|
|
|
|
func (c *fakeComponent) Start(ctx context.Context) error {
|
|
c.trace.add("start:" + c.name)
|
|
c.startCtx = ctx
|
|
if c.blockOnCancel {
|
|
close(c.startDone)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
return c.startErr
|
|
}
|
|
|
|
func (c *fakeComponent) Stop(context.Context) error {
|
|
c.trace.add("stop:" + c.name)
|
|
if c.stopStarted != nil {
|
|
c.stopOnce.Do(func() {
|
|
close(c.stopStarted)
|
|
})
|
|
}
|
|
if c.stopRelease != nil {
|
|
<-c.stopRelease
|
|
}
|
|
return c.stopErr
|
|
}
|
|
|
|
func TestHostStartStopOrdering(t *testing.T) {
|
|
trace := &traceLog{}
|
|
components := []*fakeComponent{
|
|
{name: "first", trace: trace},
|
|
{name: "second", trace: trace},
|
|
{name: "third", trace: trace},
|
|
}
|
|
host := NewHost(components[0], components[1], components[2])
|
|
|
|
if err := host.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
status := host.Status()
|
|
if got, want := status.Started, []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("Status().Started = %v, want %v", got, want)
|
|
}
|
|
|
|
// Status must not expose the host's mutable slice.
|
|
status.Started[0] = "mutated"
|
|
if got := host.Status().Started[0]; got != "first" {
|
|
t.Fatalf("Status().Started aliases internal state: got %q", got)
|
|
}
|
|
|
|
if err := host.Stop(context.Background()); err != nil {
|
|
t.Fatalf("Stop() error = %v", err)
|
|
}
|
|
if got, want := trace.snapshot(), []string{
|
|
"start:first", "start:second", "start:third",
|
|
"stop:third", "stop:second", "stop:first",
|
|
}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHostStartRollback(t *testing.T) {
|
|
trace := &traceLog{}
|
|
launchErr := errors.New("launch failed")
|
|
rollbackErr := errors.New("rollback failed")
|
|
first := &fakeComponent{name: "first", trace: trace, stopErr: rollbackErr}
|
|
second := &fakeComponent{name: "second", trace: trace, startErr: launchErr}
|
|
host := NewHost(first, second)
|
|
|
|
err := host.Start(context.Background())
|
|
if !errors.Is(err, launchErr) {
|
|
t.Fatalf("Start() error = %v, want launch error identity", err)
|
|
}
|
|
if !errors.Is(err, rollbackErr) {
|
|
t.Fatalf("Start() error = %v, want rollback error identity", err)
|
|
}
|
|
select {
|
|
case <-first.startCtx.Done():
|
|
default:
|
|
t.Fatal("started component context was not cancelled before rollback")
|
|
}
|
|
if got, want := trace.snapshot(), []string{"start:first", "start:second", "stop:first"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
|
|
status := host.Status()
|
|
if status.Failed != "second" {
|
|
t.Fatalf("Status().Failed = %q, want second", status.Failed)
|
|
}
|
|
if !errors.Is(status.LaunchErr, launchErr) {
|
|
t.Fatalf("Status().LaunchErr = %v, want launch error identity", status.LaunchErr)
|
|
}
|
|
if !errors.Is(status.StopErr, rollbackErr) {
|
|
t.Fatalf("Status().StopErr = %v, want rollback error identity", status.StopErr)
|
|
}
|
|
if !status.Stopped {
|
|
t.Fatal("Status().Stopped = false after completed rollback")
|
|
}
|
|
}
|
|
|
|
func TestHostStopIsIdempotent(t *testing.T) {
|
|
trace := &traceLog{}
|
|
stopErr := errors.New("stop failed")
|
|
component := &fakeComponent{name: "only", trace: trace, stopErr: stopErr}
|
|
host := NewHost(component)
|
|
|
|
if err := host.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
if err := host.Stop(context.Background()); !errors.Is(err, stopErr) {
|
|
t.Fatalf("first Stop() error = %v, want stop error identity", err)
|
|
}
|
|
if err := host.Stop(context.Background()); err != nil {
|
|
t.Fatalf("second Stop() error = %v, want nil", err)
|
|
}
|
|
if got, want := trace.snapshot(), []string{"start:only", "stop:only"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
status := host.Status()
|
|
if !status.Stopped || !errors.Is(status.StopErr, stopErr) {
|
|
t.Fatalf("Status() = %+v, want stopped with retained stop error", status)
|
|
}
|
|
}
|
|
|
|
func TestHostStopCancelsInProgressStart(t *testing.T) {
|
|
trace := &traceLog{}
|
|
|
|
stopStarted := make(chan struct{})
|
|
stopRelease := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
releaseStop := func() {
|
|
releaseOnce.Do(func() {
|
|
close(stopRelease)
|
|
})
|
|
}
|
|
defer releaseStop()
|
|
|
|
readyComp := &fakeComponent{
|
|
name: "ready",
|
|
trace: trace,
|
|
stopStarted: stopStarted,
|
|
stopRelease: stopRelease,
|
|
}
|
|
blockingComp := &fakeComponent{
|
|
name: "blocking",
|
|
trace: trace,
|
|
startDone: make(chan struct{}),
|
|
blockOnCancel: true,
|
|
}
|
|
|
|
host := NewHost(readyComp, blockingComp)
|
|
|
|
startDone := make(chan error, 1)
|
|
go func() {
|
|
startDone <- host.Start(context.Background())
|
|
}()
|
|
|
|
// Wait for the blocking component to enter Start and signal readiness.
|
|
select {
|
|
case <-blockingComp.startDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting for blocking component to enter Start")
|
|
}
|
|
|
|
// Concurrently call Stop while Start is blocked in the blocking component.
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
var stopErr error
|
|
go func() {
|
|
defer wg.Done()
|
|
stopErr = host.Stop(context.Background())
|
|
}()
|
|
|
|
// Assert that Status().Stopped remains false while rollback cleanup is running.
|
|
select {
|
|
case <-stopStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting for ready component rollback cleanup to start")
|
|
}
|
|
|
|
if status := host.Status(); status.Stopped {
|
|
t.Fatal("Status().Stopped = true before cleanup completed")
|
|
}
|
|
|
|
releaseStop()
|
|
|
|
// Wait for both goroutines to complete within a bounded deadline.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for Stop and Start to complete")
|
|
}
|
|
|
|
// Stop must return nil.
|
|
if stopErr != nil {
|
|
t.Fatalf("Stop() error = %v, want nil", stopErr)
|
|
}
|
|
|
|
// Start must return an error (context.Canceled propagates).
|
|
startErr := <-startDone
|
|
if startErr == nil {
|
|
t.Fatal("Start() returned nil, want context.Canceled error")
|
|
}
|
|
if !errors.Is(startErr, context.Canceled) {
|
|
t.Fatalf("Start() error = %v, want context.Canceled", startErr)
|
|
}
|
|
|
|
// The blocking component must NOT have been stopped (its Start did not succeed).
|
|
if got := trace.snapshot(); !reflect.DeepEqual(got, []string{
|
|
"start:ready", "start:blocking",
|
|
"stop:ready",
|
|
}) {
|
|
t.Fatalf("unexpected trace: %v", got)
|
|
}
|
|
|
|
// Status must be terminal.
|
|
status := host.Status()
|
|
if !status.Stopped {
|
|
t.Fatal("Status().Stopped = false, want true")
|
|
}
|
|
if !errors.Is(status.LaunchErr, context.Canceled) {
|
|
t.Fatalf("Status().LaunchErr = %v, want context.Canceled", status.LaunchErr)
|
|
}
|
|
|
|
// Repeated Stop must return nil.
|
|
if err := host.Stop(context.Background()); err != nil {
|
|
t.Fatalf("second Stop() error = %v, want nil", err)
|
|
}
|
|
}
|