iop/apps/agent/internal/host/host_test.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)
}
}